@flowdular/sdk 0.3.1 → 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 (119) hide show
  1. package/.ai/platform-capabilities.md +2 -0
  2. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  4. package/.ai/references/catalog/module.json +11 -1
  5. package/.ai/references/catalog/package.json +2 -2
  6. package/.ai/references/catalog/spec/module.yaml +25 -4
  7. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  8. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  9. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  10. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  11. package/.ai/references/catalog/src/client/api.ts +124 -36
  12. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  13. package/.ai/references/catalog/src/client/state.ts +169 -3
  14. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  15. package/.ai/references/catalog/src/domain/types.ts +20 -0
  16. package/.ai/references/catalog/src/platform.ts +20 -0
  17. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  18. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  19. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  20. package/.ai/references/catalog/src/services/migration.ts +27 -1
  21. package/.ai/references/catalog/src/services/repository.ts +31 -2
  22. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  23. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  24. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  25. package/.ai/references/catalog/tests/export.test.ts +134 -0
  26. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  27. package/.ai/references/catalog/tests/list.test.ts +217 -0
  28. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  29. package/.ai/references/catalog/tests/module.test.ts +2 -1
  30. package/.ai/references/catalog/tests/support/database.ts +14 -0
  31. package/.ai/references/catalog/translations/en.json +35 -4
  32. package/.ai/references/catalog/translations/pl.json +35 -4
  33. package/.ai/references/catalog.provenance.json +34 -26
  34. package/README.md +1 -1
  35. package/assets/flowdular-banner.webp +0 -0
  36. package/modules/access/module.json +1 -1
  37. package/modules/access/spec/module.yaml +1 -1
  38. package/modules/agents/module.json +1 -1
  39. package/modules/agents/spec/module.yaml +1 -1
  40. package/modules/approvals/module.json +1 -1
  41. package/modules/approvals/spec/module.yaml +1 -1
  42. package/modules/audit/module.json +1 -1
  43. package/modules/audit/spec/module.yaml +1 -1
  44. package/modules/auth/module.json +2 -2
  45. package/modules/auth/package.json +1 -1
  46. package/modules/auth/spec/module.yaml +2 -2
  47. package/modules/auth/src/middleware/authentication.ts +5 -1
  48. package/modules/auth/src/services/auth-service.ts +26 -0
  49. package/modules/auth/src/services/database-repository.ts +9 -0
  50. package/modules/auth/src/services/repository.ts +2 -0
  51. package/modules/automations/module.json +1 -1
  52. package/modules/automations/spec/module.yaml +1 -1
  53. package/modules/connectors/module.json +1 -1
  54. package/modules/connectors/spec/module.yaml +1 -1
  55. package/modules/directory/module.json +1 -1
  56. package/modules/directory/spec/module.yaml +1 -1
  57. package/modules/documents/module.json +1 -1
  58. package/modules/documents/spec/module.yaml +1 -1
  59. package/modules/exports/module.json +1 -1
  60. package/modules/exports/spec/module.yaml +1 -1
  61. package/modules/import/module.json +1 -1
  62. package/modules/import/spec/module.yaml +1 -1
  63. package/modules/metering/module.json +1 -1
  64. package/modules/metering/spec/module.yaml +1 -1
  65. package/modules/notifications/module.json +1 -1
  66. package/modules/notifications/spec/module.yaml +1 -1
  67. package/modules/reports/module.json +1 -1
  68. package/modules/reports/spec/module.yaml +1 -1
  69. package/modules/sandbox/module.json +1 -1
  70. package/modules/sandbox/spec/module.yaml +1 -1
  71. package/modules/search/module.json +1 -1
  72. package/modules/search/spec/module.yaml +1 -1
  73. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  74. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  75. package/modules/system/module.json +3 -2
  76. package/modules/system/package.json +2 -2
  77. package/modules/system/spec/module.yaml +98 -1
  78. package/modules/system/src/client/ModulesView.tsrx +101 -8
  79. package/modules/system/src/client/api.ts +52 -11
  80. package/modules/system/src/client/module-columns.tsrx +32 -6
  81. package/modules/system/src/client/state.ts +26 -0
  82. package/modules/system/src/domain/modules.ts +93 -0
  83. package/modules/system/src/index.ts +13 -0
  84. package/modules/system/src/platform.ts +41 -1
  85. package/modules/system/src/server/capability.ts +6 -0
  86. package/modules/system/src/server/endpoints.ts +125 -5
  87. package/modules/system/src/server/index.ts +26 -0
  88. package/modules/system/src/server/module-catalog.ts +19 -0
  89. package/modules/system/src/server/runtime.ts +102 -0
  90. package/modules/system/src/services/database-repository.ts +91 -0
  91. package/modules/system/src/services/migration.ts +39 -0
  92. package/modules/system/src/services/module-activation-service.ts +245 -0
  93. package/modules/system/src/services/repository.ts +17 -0
  94. package/modules/system/translations/en.json +17 -1
  95. package/modules/system/translations/pl.json +17 -1
  96. package/modules/users/module.json +2 -2
  97. package/modules/users/package.json +1 -1
  98. package/modules/users/spec/module.yaml +6 -9
  99. package/modules/users/src/client/UsersView.tsrx +27 -13
  100. package/modules/users/src/client/member-columns.tsrx +13 -4
  101. package/modules/users/src/client/state.ts +16 -0
  102. package/modules/users/src/services/users-service.ts +3 -0
  103. package/modules/users/translations/en.json +2 -1
  104. package/modules/users/translations/pl.json +2 -1
  105. package/modules/workflows/module.json +2 -2
  106. package/modules/workflows/package.json +1 -1
  107. package/modules/workflows/spec/module.yaml +2 -2
  108. package/modules/workflows/src/services/database-repository.ts +93 -53
  109. package/package.json +1 -1
  110. package/packages/client/package.json +1 -0
  111. package/packages/client/src/ApplicationShell.tsrx +46 -5
  112. package/packages/client/src/index.ts +5 -0
  113. package/packages/client/src/shell/modules.ts +49 -0
  114. package/packages/client/src/shell/types.ts +3 -0
  115. package/packages/contracts/src/index.ts +11 -1
  116. package/packages/server/src/endpoint.ts +17 -1
  117. package/packages/server/src/index.ts +10 -0
  118. package/packages/server/src/module-activation.ts +69 -0
  119. package/assets/flowdular-banner.png +0 -0
@@ -11,14 +11,14 @@ export function memberColumns(
11
11
  {
12
12
  key: 'displayName',
13
13
  header: t('users.table.user'),
14
- width: '22%',
14
+ width: '20%',
15
15
  value: (user) => user.displayName,
16
16
  cell: (user) => <b>{user.displayName}</b>,
17
17
  },
18
18
  {
19
19
  key: 'email',
20
20
  header: t('users.table.email'),
21
- width: '24%',
21
+ width: '22%',
22
22
  value: (user) => user.email,
23
23
  cell: (user) => <span>{user.email}</span>,
24
24
  },
@@ -34,7 +34,7 @@ export function memberColumns(
34
34
  {
35
35
  key: 'status',
36
36
  header: t('users.table.status'),
37
- width: '28%',
37
+ width: '22%',
38
38
  cell: (user) => <span class="ui-table__state">
39
39
  <Tag
40
40
  tone={user.membershipStatus === 'active' ? 'success' : 'neutral'}
@@ -55,11 +55,20 @@ export function memberColumns(
55
55
  {
56
56
  key: 'scopes',
57
57
  header: t('users.table.scopes'),
58
- width: '12%',
58
+ width: '10%',
59
59
  numeric: true,
60
60
  cell: (user) => new Intl.NumberFormat(activeLocale()).format(
61
61
  user.scopes.length,
62
62
  ),
63
63
  },
64
+ /* No `value`: the server's sorted read has no keyset over created_at. */
65
+ {
66
+ key: 'createdAt',
67
+ header: t('users.table.joined'),
68
+ width: '12%',
69
+ cell: (user) => new Intl.DateTimeFormat(activeLocale(), {
70
+ dateStyle: 'medium',
71
+ }).format(user.createdAt),
72
+ },
64
73
  ];
65
74
  }
@@ -46,6 +46,7 @@ export function createUsersClientState() {
46
46
  actor: cell<UsersContext['actor'] | null>(null),
47
47
  passwordMinLength: 12,
48
48
  memberCount: 0,
49
+ ownerCount: 0,
49
50
  query: '',
50
51
  /** The term the rows on screen answer; typing moves `query` ahead of it. */
51
52
  appliedQuery: '',
@@ -192,6 +193,21 @@ export function replaceMember(
192
193
  );
193
194
  }
194
195
 
196
+ /**
197
+ * The owner count after one row changed on screen: the count is read whole
198
+ * from the server, so a role change moves it by the difference between the row
199
+ * the page held and the row the server answered.
200
+ */
201
+ export function ownerCountAfter(
202
+ ownerCount: number,
203
+ previous: TenantMember | undefined,
204
+ next: TenantMember,
205
+ ): number {
206
+ const before = previous?.role === 'owner' ? 1 : 0;
207
+ const after = next.role === 'owner' ? 1 : 0;
208
+ return ownerCount - before + after;
209
+ }
210
+
195
211
  /* The membership route answers with stable codes; each one gets the sentence
196
212
  that says what to do instead, and anything else keeps the server message. */
197
213
  export function membershipStatusMessage(error: unknown): string {
@@ -54,6 +54,8 @@ export interface UsersContext {
54
54
  readonly passwordMinLength: number;
55
55
  /** Every membership of the workspace, counted in the database. */
56
56
  readonly memberCount: number;
57
+ /** The memberships holding the owner role, counted the same way. */
58
+ readonly ownerCount: number;
57
59
  }
58
60
 
59
61
  function actor(principal: AuthPrincipal): AuthActor {
@@ -105,6 +107,7 @@ export class UsersService {
105
107
  actor: { accountId: principal.accountId, role: principal.role },
106
108
  passwordMinLength: this.#auth.settings.passwordMinLength,
107
109
  memberCount: await service.countTenantMembers(principal.tenantId),
110
+ ownerCount: await service.countTenantOwners(principal.tenantId),
108
111
  };
109
112
  }
110
113
 
@@ -35,12 +35,13 @@
35
35
  "denied.members": "Reading the user directory needs the users.members.read scope.",
36
36
  "table.title": "Members",
37
37
  "table.caption": "Workspace users",
38
- "table.count": "{accounts} accounts",
38
+ "table.count": "{accounts} accounts, {owners} owners",
39
39
  "table.user": "User",
40
40
  "table.email": "Email",
41
41
  "table.role": "Role",
42
42
  "table.status": "Workspace access",
43
43
  "table.scopes": "Scopes",
44
+ "table.joined": "Joined",
44
45
  "table.actions": "User actions",
45
46
  "table.loading": "Loading workspace accounts…",
46
47
  "table.emptyTitle": "No accounts yet",
@@ -35,12 +35,13 @@
35
35
  "denied.members": "Odczyt katalogu użytkowników wymaga uprawnienia users.members.read.",
36
36
  "table.title": "Członkowie",
37
37
  "table.caption": "Użytkownicy przestrzeni roboczej",
38
- "table.count": "Konta: {accounts}",
38
+ "table.count": "Konta: {accounts}, właściciele: {owners}",
39
39
  "table.user": "Użytkownik",
40
40
  "table.email": "E-mail",
41
41
  "table.role": "Rola",
42
42
  "table.status": "Dostęp do przestrzeni",
43
43
  "table.scopes": "Zakresy",
44
+ "table.joined": "Dołączył(a)",
44
45
  "table.actions": "Akcje użytkownika",
45
46
  "table.loading": "Wczytywanie kont…",
46
47
  "table.emptyTitle": "Brak kont",
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "workflows.core",
5
5
  "package": "@flowdular/module-workflows",
6
- "version": "0.6.7",
6
+ "version": "0.6.8",
7
7
  "profile": "full",
8
8
  "capabilities": [
9
9
  "api",
@@ -20,7 +20,7 @@
20
20
  "dependencies": [
21
21
  {
22
22
  "id": "system.core",
23
- "range": "^0.7.0"
23
+ "range": "^0.8.0"
24
24
  },
25
25
  {
26
26
  "id": "auth.core",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-workflows",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -1,6 +1,6 @@
1
1
  schemaVersion: 1
2
2
  id: workflows.core
3
- specVersion: 0.6.7
3
+ specVersion: 0.6.8
4
4
  status: approved
5
5
  name: Agentic Workflows
6
6
  description: Builds, publishes, simulates, and executes durable tenant-scoped DAG workflows whose versioned nodes coordinate pinned agents, deterministic decisions, validated data, and registered module actions.
@@ -14,7 +14,7 @@ capabilities:
14
14
  - cli
15
15
  dependencies:
16
16
  - id: system.core
17
- range: ^0.7.0
17
+ range: ^0.8.0
18
18
  - id: auth.core
19
19
  range: ^0.13.0
20
20
  - id: agents.core
@@ -3,6 +3,7 @@ import {
3
3
  integer,
4
4
  runDatabaseMigrations,
5
5
  type DatabaseHandle,
6
+ type DatabaseIsolationLevel,
6
7
  type DatabaseTransaction,
7
8
  } from '@flowdular/sdk/database';
8
9
  import type { Actor, UserActor } from '@flowdular/sdk/kernel';
@@ -868,9 +869,14 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
868
869
  tenantId: string,
869
870
  access: 'read' | 'write',
870
871
  body: (transaction: DatabaseTransaction) => Promise<T>,
872
+ isolation?: DatabaseIsolationLevel,
871
873
  ): Promise<T> {
872
874
  await this.readyPromise;
873
- return this.handles.runtime.transaction(body, { access, tenantId });
875
+ return this.handles.runtime.transaction(body, {
876
+ access,
877
+ tenantId,
878
+ ...(isolation ? { isolation } : {}),
879
+ });
874
880
  }
875
881
 
876
882
  async #query<Row extends object>(
@@ -1567,21 +1573,34 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
1567
1573
  tenantId: string,
1568
1574
  runId: string,
1569
1575
  ): Promise<WorkflowRunDetail | null> {
1570
- const row = await this.#tx(
1576
+ /* A worker commits a node transition together with its events, so the
1577
+ rebuild only agrees with the rows when every read here shares one
1578
+ snapshot. Read committed would take a new one per statement. */
1579
+ const snapshot = await this.#tx(
1571
1580
  tenantId,
1572
1581
  'read',
1573
- async (transaction) =>
1574
- (
1582
+ async (transaction) => {
1583
+ const row = (
1575
1584
  await this.#query<RunRow>(transaction, SQL.getRun, [tenantId, runId])
1576
- )[0],
1577
- );
1578
- if (!row) return null;
1579
- const events = await this.readEvents(
1580
- tenantId,
1581
- runId,
1582
- 0,
1583
- WORKFLOW_LIMITS.maxRunEvents,
1585
+ )[0];
1586
+ if (!row) return null;
1587
+ return {
1588
+ row,
1589
+ events: await this.#eventsIn(
1590
+ transaction,
1591
+ tenantId,
1592
+ runId,
1593
+ 0,
1594
+ WORKFLOW_LIMITS.maxRunEvents,
1595
+ ),
1596
+ nodes: await this.#nodeStatesIn(transaction, tenantId, runId),
1597
+ edges: await this.#edgeTransfersIn(transaction, tenantId, runId),
1598
+ };
1599
+ },
1600
+ 'repeatable-read',
1584
1601
  );
1602
+ if (!snapshot) return null;
1603
+ const { row, events, nodes, edges } = snapshot;
1585
1604
  const projection = projectWorkflowRunEvents(events);
1586
1605
  if (projection.status !== row.status) {
1587
1606
  throw new WorkflowEventProjectionError(
@@ -1589,7 +1608,6 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
1589
1608
  `Workflow run projection is ${row.status} but its event stream rebuilds as ${projection.status}.`,
1590
1609
  );
1591
1610
  }
1592
- const nodes = await this.readNodeStates(tenantId, runId);
1593
1611
  for (const [nodeId, status] of Object.entries(projection.nodeStatuses)) {
1594
1612
  if (nodes.find((node) => node.nodeId === nodeId)?.status !== status) {
1595
1613
  throw new WorkflowEventProjectionError(
@@ -1612,7 +1630,6 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
1612
1630
  );
1613
1631
  }
1614
1632
  }
1615
- const edges = await this.readEdgeTransfers(tenantId, runId);
1616
1633
  for (const [edgeId, state] of Object.entries(projection.edgeStates)) {
1617
1634
  if (edges.find((edge) => edge.edgeId === edgeId)?.state !== state) {
1618
1635
  throw new WorkflowEventProjectionError(
@@ -1826,21 +1843,28 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
1826
1843
  afterSequence: number,
1827
1844
  limit: number,
1828
1845
  ): Promise<readonly WorkflowRunEventV1[]> {
1829
- return this.#tx(tenantId, 'read', async (transaction) =>
1830
- (
1831
- await this.#query<EventRow>(transaction, SQL.readEvents, [
1832
- tenantId,
1833
- runId,
1834
- Math.max(0, Math.trunc(afterSequence)),
1835
- Math.min(
1836
- Math.max(1, Math.trunc(limit)),
1837
- WORKFLOW_LIMITS.maxRunEvents,
1838
- ),
1839
- ])
1840
- ).map(eventFromRow),
1846
+ return this.#tx(tenantId, 'read', (transaction) =>
1847
+ this.#eventsIn(transaction, tenantId, runId, afterSequence, limit),
1841
1848
  );
1842
1849
  }
1843
1850
 
1851
+ async #eventsIn(
1852
+ transaction: DatabaseTransaction,
1853
+ tenantId: string,
1854
+ runId: string,
1855
+ afterSequence: number,
1856
+ limit: number,
1857
+ ): Promise<readonly WorkflowRunEventV1[]> {
1858
+ return (
1859
+ await this.#query<EventRow>(transaction, SQL.readEvents, [
1860
+ tenantId,
1861
+ runId,
1862
+ Math.max(0, Math.trunc(afterSequence)),
1863
+ Math.min(Math.max(1, Math.trunc(limit)), WORKFLOW_LIMITS.maxRunEvents),
1864
+ ])
1865
+ ).map(eventFromRow);
1866
+ }
1867
+
1844
1868
  async startAttempt(
1845
1869
  write: StartAttemptWrite,
1846
1870
  actor: Actor,
@@ -2516,42 +2540,58 @@ export class DatabaseWorkflowsRepository implements WorkflowsRepository {
2516
2540
  tenantId: string,
2517
2541
  runId: string,
2518
2542
  ): Promise<readonly WorkflowNodeExecution[]> {
2519
- return this.#tx(tenantId, 'read', async (transaction) => {
2520
- const states = await this.#query<NodeStateRow>(
2521
- transaction,
2522
- SQL.readNodeStates,
2523
- [tenantId, runId],
2524
- );
2525
- const attempts = await this.#query<AttemptRow>(
2526
- transaction,
2527
- SQL.readAttempts,
2528
- [tenantId, runId],
2529
- );
2530
- return states.map((state) =>
2531
- nodeExecutionFromRow(
2532
- state,
2533
- attempts
2534
- .filter((attempt) => attempt.node_id === state.node_id)
2535
- .map(attemptFromRow),
2536
- ),
2537
- );
2538
- });
2543
+ return this.#tx(tenantId, 'read', (transaction) =>
2544
+ this.#nodeStatesIn(transaction, tenantId, runId),
2545
+ );
2546
+ }
2547
+
2548
+ async #nodeStatesIn(
2549
+ transaction: DatabaseTransaction,
2550
+ tenantId: string,
2551
+ runId: string,
2552
+ ): Promise<readonly WorkflowNodeExecution[]> {
2553
+ const states = await this.#query<NodeStateRow>(
2554
+ transaction,
2555
+ SQL.readNodeStates,
2556
+ [tenantId, runId],
2557
+ );
2558
+ const attempts = await this.#query<AttemptRow>(
2559
+ transaction,
2560
+ SQL.readAttempts,
2561
+ [tenantId, runId],
2562
+ );
2563
+ return states.map((state) =>
2564
+ nodeExecutionFromRow(
2565
+ state,
2566
+ attempts
2567
+ .filter((attempt) => attempt.node_id === state.node_id)
2568
+ .map(attemptFromRow),
2569
+ ),
2570
+ );
2539
2571
  }
2540
2572
 
2541
2573
  async readEdgeTransfers(
2542
2574
  tenantId: string,
2543
2575
  runId: string,
2544
2576
  ): Promise<readonly WorkflowEdgeTransfer[]> {
2545
- return this.#tx(tenantId, 'read', async (transaction) =>
2546
- (
2547
- await this.#query<EdgeRow>(transaction, SQL.readEdgeTransfers, [
2548
- tenantId,
2549
- runId,
2550
- ])
2551
- ).map(edgeFromRow),
2577
+ return this.#tx(tenantId, 'read', (transaction) =>
2578
+ this.#edgeTransfersIn(transaction, tenantId, runId),
2552
2579
  );
2553
2580
  }
2554
2581
 
2582
+ async #edgeTransfersIn(
2583
+ transaction: DatabaseTransaction,
2584
+ tenantId: string,
2585
+ runId: string,
2586
+ ): Promise<readonly WorkflowEdgeTransfer[]> {
2587
+ return (
2588
+ await this.#query<EdgeRow>(transaction, SQL.readEdgeTransfers, [
2589
+ tenantId,
2590
+ runId,
2591
+ ])
2592
+ ).map(edgeFromRow);
2593
+ }
2594
+
2555
2595
  /* The chain order is the sequence; the time order rides the index 0001 made
2556
2596
  for it, with the sequence as the tie breaker in both. */
2557
2597
  async listAudit(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sdk",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Flowdular platform SDK: server, client, UI, data, agents and core modules through separate exports.",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "test": "vitest run --passWithNoTests"
14
14
  },
15
15
  "dependencies": {
16
+ "@flowdular/contracts": "workspace:*",
16
17
  "@flowdular/ui": "workspace:*",
17
18
  "@octanejs/seo": "0.0.38",
18
19
  "octane": "0.1.51",
@@ -13,6 +13,10 @@ import { CommandPalette } from './shell/CommandPalette.tsrx';
13
13
  import { widgetsForSlot } from './shell/ContributionOutlets.tsrx';
14
14
  import { registerModuleTranslations, useTranslation } from './i18n/runtime.ts';
15
15
  import { MobileHeader } from './shell/MobileHeader.tsrx';
16
+ import {
17
+ contributionsForActiveModules,
18
+ loadActiveModules,
19
+ } from './shell/modules.ts';
16
20
  import {
17
21
  coreNavigation,
18
22
  navigationForIdentity,
@@ -44,10 +48,6 @@ export function ApplicationShell(props: ApplicationShellProps) @{
44
48
  locale is side-effect free for subscribers. */
45
49
  registerModuleTranslations(props.contributions ?? []);
46
50
  const { t } = useTranslation();
47
- const registry = useMemo(
48
- () => createClientContributionRegistry(props.contributions ?? []),
49
- [props.contributions],
50
- );
51
51
  const shell = useMemo(() => createShellState(props.initialView), [
52
52
  props.initialView,
53
53
  ]);
@@ -60,12 +60,28 @@ export function ApplicationShell(props: ApplicationShellProps) @{
60
60
  ? null
61
61
  : props.identity.tenantId,
62
62
  ),
63
+ /* The workspace whose active modules are known, and which they are;
64
+ null ids show every module, so a failed read never empties the shell. */
65
+ activationTenantId: cell<string | null>(null),
66
+ activeModules: cell<readonly string[] | null>(null),
63
67
  }),
64
68
  [],
65
69
  );
66
70
  const [initializedTenant, setInitializedTenant] = useValue(
67
71
  initialization.state.tenantId,
68
72
  );
73
+ const [activationTenant, setActivationTenant] = useValue(
74
+ initialization.state.activationTenantId,
75
+ );
76
+ const [activeModules, setActiveModules] = useValue(
77
+ initialization.state.activeModules,
78
+ );
79
+ const registry = useMemo(
80
+ () => createClientContributionRegistry(
81
+ contributionsForActiveModules(props.contributions ?? [], activeModules),
82
+ ),
83
+ [props.contributions, activeModules],
84
+ );
69
85
  const [activeView, setActiveView] = useValue(shell.state.activeView);
70
86
  const [commandOpen, setCommandOpen] = useValue(shell.state.commandOpen);
71
87
  const [navigationOpen, setNavigationOpen] = useValue(
@@ -80,7 +96,9 @@ export function ApplicationShell(props: ApplicationShellProps) @{
80
96
  const [query, setQuery] = useValue(shell.state.query);
81
97
  const [tenantBusy, setTenantBusy] = useValue(shell.state.tenantBusy);
82
98
  const [tenantError, setTenantError] = useValue(shell.state.tenantError);
83
- const workspaceInitialized = initializedTenant === props.identity.tenantId;
99
+ const workspaceInitialized =
100
+ initializedTenant === props.identity.tenantId &&
101
+ activationTenant === props.identity.tenantId;
84
102
  const grantedScopes = new Set(props.identity.scopes);
85
103
  const visibleNavigation = navigationForIdentity(
86
104
  [...coreNavigation(t), ...registry.navigation],
@@ -222,6 +240,29 @@ export function ApplicationShell(props: ApplicationShellProps) @{
222
240
  return () => controller.abort();
223
241
  }, [props.identity.accountId, props.identity.tenantId]);
224
242
 
243
+ useEffect(() => {
244
+ const controller = new AbortController();
245
+ setActivationTenant(null);
246
+ const resolveActiveModules = props.loadActiveModules ?? loadActiveModules;
247
+ void resolveActiveModules({
248
+ accountId: props.identity.accountId,
249
+ tenantId: props.identity.tenantId,
250
+ signal: controller.signal,
251
+ }).then(
252
+ (ids) => {
253
+ if (controller.signal.aborted) return;
254
+ setActiveModules(ids);
255
+ setActivationTenant(props.identity.tenantId);
256
+ },
257
+ () => {
258
+ if (controller.signal.aborted) return;
259
+ setActiveModules(null);
260
+ setActivationTenant(props.identity.tenantId);
261
+ },
262
+ );
263
+ return () => controller.abort();
264
+ }, [props.identity.accountId, props.identity.tenantId]);
265
+
225
266
  useEffect(() => {
226
267
  try {
227
268
  if (localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'rail') {
@@ -25,6 +25,11 @@ export type {
25
25
  WorkspaceSlot,
26
26
  } from './contributions.ts';
27
27
  export { openCommandPalette } from './shell/command.ts';
28
+ export {
29
+ contributionsForActiveModules,
30
+ loadActiveModules,
31
+ } from './shell/modules.ts';
32
+ export type { ActiveModulesLoader } from './shell/modules.ts';
28
33
  export {
29
34
  COMMAND_SEARCH_DEBOUNCE_MS,
30
35
  COMMAND_SEARCH_MINIMUM,
@@ -0,0 +1,49 @@
1
+ import { REQUIRED_MODULE_IDS } from '@flowdular/sdk/contracts';
2
+ import type {
3
+ ModuleClientContribution,
4
+ ModuleClientInitializationContext,
5
+ } from '../contributions.ts';
6
+
7
+ const REQUIRED = new Set<string>(REQUIRED_MODULE_IDS);
8
+
9
+ export type ActiveModulesLoader = (
10
+ context: ModuleClientInitializationContext,
11
+ ) => Promise<readonly string[] | null>;
12
+
13
+ /**
14
+ * The contributions the workspace may show: every required module, and the
15
+ * optional ones the workspace has active. `null` means the activation could
16
+ * not be read, and the shell shows everything rather than nothing; the server
17
+ * gate still refuses the API of an inactive module.
18
+ */
19
+ export function contributionsForActiveModules(
20
+ contributions: readonly ModuleClientContribution[],
21
+ activeModules: readonly string[] | null,
22
+ ): readonly ModuleClientContribution[] {
23
+ if (activeModules === null) return contributions;
24
+ const active = new Set(activeModules);
25
+ return contributions.filter(
26
+ (contribution) =>
27
+ REQUIRED.has(contribution.moduleId) || active.has(contribution.moduleId),
28
+ );
29
+ }
30
+
31
+ /* system.core answers the active composed module ids to any member of the
32
+ workspace; an application without it, or a failed read, answers null. */
33
+ export const loadActiveModules: ActiveModulesLoader = async (context) => {
34
+ try {
35
+ const response = await fetch('/api/system/modules/active', {
36
+ headers: { accept: 'application/json' },
37
+ credentials: 'same-origin',
38
+ signal: context.signal,
39
+ });
40
+ if (!response.ok) return null;
41
+ const body = (await response.json()) as { modules?: unknown };
42
+ return Array.isArray(body.modules) &&
43
+ body.modules.every((id) => typeof id === 'string')
44
+ ? (body.modules as string[])
45
+ : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ };
@@ -1,5 +1,6 @@
1
1
  import type { ModuleClientContribution } from '../contributions.ts';
2
2
  import type { ShellView } from '../state.ts';
3
+ import type { ActiveModulesLoader } from './modules.ts';
3
4
 
4
5
  export interface ShellTenant {
5
6
  readonly tenantId: string;
@@ -22,6 +23,8 @@ export interface ApplicationShellProps {
22
23
  initialView?: ShellView;
23
24
  contributions?: readonly ModuleClientContribution[];
24
25
  identity: ShellIdentity;
26
+ /** Reads the active module ids of the workspace; defaults to system.core's endpoint. */
27
+ loadActiveModules?: ActiveModulesLoader;
25
28
  onSignOut: () => void | Promise<void>;
26
29
  onSwitchTenant: (tenantId: string) => void | Promise<void>;
27
30
  }
@@ -4,7 +4,7 @@
4
4
  removed or changed. The surface is pinned by
5
5
  packages/kernel/platform-api.snapshot.d.ts; `pnpm platform-api:check` fails
6
6
  when the surface changes without a bump here. */
7
- export const PLATFORM_API_VERSION = '0.1.8';
7
+ export const PLATFORM_API_VERSION = '0.1.9';
8
8
 
9
9
  /* The workspace time zone: one tenant setting, declared by the module named
10
10
  here and read by any module that shows or schedules a local time. The id, the
@@ -15,6 +15,16 @@ export const TENANT_TIME_ZONE_SETTING = {
15
15
  defaultValue: 'UTC',
16
16
  } as const;
17
17
 
18
+ /* Modules every workspace needs to sign in, hold members and show the shell.
19
+ They are composed like any other module but never deactivated per workspace,
20
+ and the endpoint gate never asks about them. */
21
+ export const REQUIRED_MODULE_IDS = [
22
+ 'system.core',
23
+ 'auth.core',
24
+ 'users.core',
25
+ 'profile.core',
26
+ ] as const;
27
+
18
28
  export type ModuleCapability =
19
29
  | 'api'
20
30
  | 'database'
@@ -1,6 +1,7 @@
1
1
  import { ServerRoute, type Context } from '@octanejs/app-core';
2
2
  import { serverLogger } from './log.ts';
3
3
  import { serverMetrics } from './metrics.ts';
4
+ import { MODULE_INACTIVE, routeActiveForTenant } from './module-activation.ts';
4
5
  import {
5
6
  formatTraceParent,
6
7
  runWithTrace,
@@ -11,6 +12,8 @@ import { serverTracer, type Span } from './trace/tracer.ts';
11
12
  export interface EndpointIdentity {
12
13
  readonly subjectId: string;
13
14
  readonly permissions: ReadonlySet<string>;
15
+ /** The workspace the identity acts in; the module activation gate reads it. */
16
+ readonly tenantId?: string;
14
17
  }
15
18
 
16
19
  export interface EndpointExecutionContext {
@@ -131,6 +134,19 @@ export function defineEndpoint(
131
134
  traceparent,
132
135
  );
133
136
  }
137
+ if (
138
+ identity.tenantId !== undefined &&
139
+ !(await routeActiveForTenant(serverRoute, identity.tenantId))
140
+ ) {
141
+ status = 403;
142
+ return problem(
143
+ 403,
144
+ MODULE_INACTIVE,
145
+ 'This module is inactive in the workspace.',
146
+ requestId,
147
+ traceparent,
148
+ );
149
+ }
134
150
  }
135
151
 
136
152
  const response = await definition.handler({
@@ -171,7 +187,7 @@ export function defineEndpoint(
171
187
  }
172
188
  };
173
189
 
174
- const serverRoute = new ServerRoute({
190
+ const serverRoute: ServerRoute = new ServerRoute({
175
191
  path: definition.path,
176
192
  methods: definition.methods.map((method) => method.toUpperCase()),
177
193
  handler: (context) => {
@@ -202,3 +202,13 @@ export type {
202
202
  ModuleServerContext,
203
203
  ModuleServerComposition,
204
204
  } from './composition.ts';
205
+ export {
206
+ bindModuleCompositions,
207
+ bindModuleRoutes,
208
+ installModuleActivationGate,
209
+ isRequiredModule,
210
+ MODULE_INACTIVE,
211
+ moduleOfRoute,
212
+ routeActiveForTenant,
213
+ } from './module-activation.ts';
214
+ export type { ModuleActivationGate } from './module-activation.ts';