@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
@@ -24,6 +24,8 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
24
24
 
25
25
  **Feature flags.** A flag is a module setting declared `kind: 'flag'`: a non-secret `boolean` with a `defaultValue`, a `label`, a `description` and `scope: 'tenant'`, which is the default and the only scope a flag may take; `defineModuleSettings` refuses anything else, a platform-scoped flag included. There is no flag store, no flag endpoint and no flag registry. A module reads one on the request path with the ordinary settings read, `context.settings.get<boolean>(tenantId, moduleId, key)`: a lookup of the declaration, a lookup of the cached per `(tenant, module)` value set under a key the read builds, the touch that keeps that entry at the head of the cache, the property read and one `typeof` check. No query of its own, and nothing beyond what any setting already costs: a declared `pattern` is compiled once with the declaration, never per read. There is deliberately no `context.flags`; the read is the settings read, and the module id is the one the module already knows. An override is per workspace behind `system.settings.manage` on the Flags tab of Administration, Modules, which groups every declared flag by its owning module and links the audit trail. Every change appends one `settings.flag.changed` auth audit event with the module, the key, the previous and next values and the actor (`settings.updated` stays the event for every other setting). No percentage rollout and no targeting: a flag is on or off for a workspace.
26
26
 
27
+ **Module activation.** The composed module set is CLI-owned and baked at build; what an owner changes from Administration, Modules is per-workspace activation of the modules the application already composes. `system.core` keeps it in `system_module_activations` (a composed module without a row is active), lists it through `GET /api/system/modules` (every catalog row with `active`, `optional` and `dependents`) and `GET /api/system/modules/active` (the active composed ids, for any member holding `system.workspace.access`), and changes it through `POST /api/system/modules/activate` and `/api/system/modules/deactivate` behind `system.settings.manage` with CSRF first. `system.core`, `auth.core`, `users.core` and `profile.core` (`REQUIRED_MODULE_IDS` in `@flowdular/contracts`) are never deactivated; a module another active module depends on, through a declared module dependency or a required capability, is refused with 409 `MODULE_HAS_ACTIVE_DEPENDENTS` naming the dependents, a required one with 409 `MODULE_REQUIRED`, and activating a module whose dependency is inactive with 409 `MODULE_DEPENDENCY_INACTIVE`. Every change appends one `system.module.activated` or `system.module.deactivated` auth audit event. The state is one per-tenant snapshot memoised for 30 seconds and published as the public capability `system.modules.v1` (`isActive(tenantId, moduleId)`, `activeIds(tenantId)`, `modules/system/src/server/capability.ts`). Enforcement costs a module nothing: the generated composition binds every route to its module id (`bindModuleCompositions`, `packages/server/src/module-activation.ts`), `defineEndpoint` answers 403 `MODULE_INACTIVE` after the permission check for an endpoint of an inactive module in the principal's workspace (`EndpointIdentity.tenantId` comes from `endpointIdentityFromContext`), and the application shell reads the active ids before it renders and hides the navigation, views, widgets and command search of an inactive module (`contributionsForActiveModules`, `packages/client/src/shell/modules.ts`; a failed read shows everything). Not covered yet: the agent tools of an inactive module are still offered, because the harness registry has no per-tenant module hook.
28
+
27
29
  **Navigation groups.** A navigation contribution picks exactly one group (`NavigationGroup`, `packages/client/src/contributions.ts`):
28
30
 
29
31
  <!-- capabilities:navigation-groups -->
@@ -50,7 +52,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
50
52
 
51
53
  **Automations triggers.** Two mechanisms in `automations.core`. A schedule uses a cadence string in one of two forms (`modules/automations/src/domain/cadence.ts`): `every:N`, where `N` is whole minutes from 1 to 10080 and the unit is implicit, or `cron:<minute> <hour> <day of month> <month> <day of week>`, five fields with no seconds, at most 100 characters, each field `*`, a number, a three letter month or weekday name, a list, a range, or any of those with a step (`*/15`, `9-17/4`); both day fields restricted means either matches. A cron slot is a wall clock time in the workspace zone from the shared `system.core.timeZone` setting, stored in UTC; a wall time a daylight saving change removes is skipped and one that occurs twice fires at the first of the two. An expression the module cannot honour is refused at save with `INVALID_CADENCE`. Missed slots are skipped, never replayed. An inbound webhook posts to `POST /api/automations/triggers/:id/fire`, authenticated by an HMAC signature in `x-flowdular-signature` with `x-flowdular-timestamp` inside a 5 minute window and a 16 KB body cap, answering `202 { accepted, runId }`. Targets are pluggable through `automations.targets.v1`; the shipped kinds are `agent` and `workflow`.
52
54
 
53
- **CLI extensions.** A module contributes `pnpm flowdular <namespace> <action>` through `src/cli/commands.json` plus `defineCliExtension` (`packages/cli-protocol/src/index.ts`), declared in `module.json` under `cli`. The first path segment must equal the module id's first segment and may not be a reserved group (`help`, `doctor`, `capability`, `spec`, `blueprint`, `module`, `migration`, `setup`). Each command carries a `CapabilityDescriptor` with `id`, `version`, `summary`, `risk: 'read' | 'workspace-write' | 'process' | 'external' | 'destructive'`, `requiresApprovedSpec`, `supportsDryRun`, optional `localOnly` and `confirmation`. The runner refuses `external` always and non-local `destructive` always, gates `localOnly` to `development` and `test`, and treats every write command as a dry run without `--apply`.
55
+ **CLI extensions.** A module contributes `pnpm flowdular <namespace> <action>` through `src/cli/commands.json` plus `defineCliExtension` (`packages/cli-protocol/src/index.ts`), declared in `module.json` under `cli`. The first path segment must equal the module id's first segment and may not be a reserved group (`help`, `doctor`, `capability`, `spec`, `blueprint`, `module`, `migration`, `setup`). Each command carries a `CapabilityDescriptor` with `id`, `version`, `summary`, `risk: 'read' | 'workspace-write' | 'process' | 'external' | 'destructive'`, `requiresApprovedSpec`, `supportsDryRun`, optional `localOnly` and `confirmation`. The runner refuses `external` and non-local `destructive` commands unless `--grant <token> --tenant <id>` carries a verified approval grant for the capability and the invocation digest, gates `localOnly` to `development` and `test`, and treats every write command as a dry run without `--apply`.
54
56
 
55
57
  **Translations.** The shipped locales are `en` and `pl`. A module keeps a flat bundle per locale in `translations/<locale>.json`, imports them in `src/client/contribution.tsrx` and returns `translations: { en, pl }`. Keys resolve fully qualified as `<first module segment>.<key>` through `t()`. `flowdular module validate` reports `TRANSLATION_FILE_MISSING`, `TRANSLATION_PARSE_ERROR`, `TRANSLATION_KEYS_MISMATCH` and `TRANSLATION_KEY_MISSING`.
56
58
 
@@ -66,7 +68,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
66
68
 
67
69
  **Connectors.** `connectors.core` (optional) is the governed way out to an external system. A module ships a connector definition through the public capability `connectors.definitions.v1` (`modules/connectors/src/domain/definitions.ts`): `register({ key, moduleId, label, authKinds, operations: [{ key, label, method, path, inputSchema, outputSchema }], defaultAllowedHosts, allowedPorts? })` (ports default to 443 only), and the platform ships `http-json`. An owner creates an instance behind `connectors.instances.manage` with a base URL, sealed credentials under `FD_CONNECTORS_SECRET_KEY`, a host allowlist and two consent flags, `allowWorkflows` and `allowAgents`, both off. A call goes through `connectors.calls.v1`: `call({ tenantId, instanceId, operation, input, caller: 'test' | 'workflow' | 'agent', callerRef? })`, which enforces status, consent for that caller kind, the egress policy (https, no private addresses, no redirects, timeout and size caps) and logs the call without any body; `consented(tenantId, instanceId, caller)` answers admission alone. The agent tool `connectors.call` is declared `workspace-write` with the harness consent gate `connectors.instance-consent` (`AgentToolConsent` in `packages/harness/src/runtime.ts`), carries the full action contract (`idempotency: 'required'` backed by a per-call key ledger) so workflows may use it, has no HTTP route of its own, and dials only the addresses the egress policy verified, so the ceiling is raised per instance by the owner's consent, never by a declaration.
68
70
 
69
- **Approvals.** `approvals.core` (optional) turns a policy's `requiresApproval` into a request people decide. A module opens one through the public capability `approvals.requests.v1` (`modules/approvals/src/domain/capability.ts`): `open({ tenantId, subjectModule, subjectRef, permission, action, title, summary?, requesterAccountId, requirement, onResolved? })`, plus `get`, `list` and `cancel`. Eligible deciders are resolved at open from the requirement's role key and scope (both, when both are named; the requester never decides), re-read at decision time, and a request needs `decisions` approvals before `expiresInDays` runs out. Open is idempotent per subject while a request is pending, and a requirement that resolves to nobody, to too few or to more than 200 deciders is refused with a stable code. Decisions are an append-only ledger behind `approvals.requests.read`, `approvals.requests.decide` and `approvals.requests.manage`; deciders and requesters are notified through the kinds `approval-requested` and `approval-decided`. The subject module learns the outcome from `onResolved`, which runs once per terminal state after the deciding transaction commits, or by reading the request back.
71
+ **Approvals.** `approvals.core` (optional) turns a policy's `requiresApproval` into a request people decide. A module opens one through the public capability `approvals.requests.v1` (`modules/approvals/src/domain/capability.ts`): `open({ tenantId, subjectModule, subjectRef, permission, action, title, summary?, requesterAccountId, requirement, onResolved? })`, plus `get`, `list` and `cancel`. Eligible deciders are resolved at open from the requirement's role key and scope (both, when both are named; the requester never decides), re-read at decision time, and a request needs `decisions` approvals before `expiresInDays` runs out. Open is idempotent per subject while a request is pending, and a requirement that resolves to nobody, to too few or to more than 200 deciders is refused with a stable code. Decisions are an append-only ledger behind `approvals.requests.read`, `approvals.requests.decide` and `approvals.requests.manage`; deciders and requesters are notified through the kinds `approval-requested` and `approval-decided`. The subject module learns the outcome from `onResolved`, which runs once per terminal state after the deciding transaction commits, or by reading the request back. A request whose `subjectRef` is `encodeCapabilitySubjectRef({ capabilityId, inputDigest })` yields, once approved, a signed token through `grant(tenantId, id, subjectModule)`, handed only to the module that opened it; the CLI runner takes it as `--grant` (valid until expiry) and the harness as `AgentExecutionRequest.grants` (one tool call per grant), both bound to the tenant, the capability id and `approvalInputDigest` of the input.
70
72
 
71
73
  **Documents.** `documents.core` (optional) owns file attachments of any record. A screen uploads through `POST /api/documents/upload` with the raw body and the headers `x-document-filename`, `x-document-owner-module`, `x-document-record-ref` and `x-document-description`, lists with `GET /api/documents?ownerModule=&recordRef=`, opens through `POST /api/documents/read-url` (a short-lived storage URL) and deletes through `POST /api/documents/delete`, all behind `documents.files.read` or `documents.files.manage` with CSRF first. A module reads its own records' attachments through the public capability `documents.attachments.v1` (`modules/documents/src/domain/attachments.ts`): `list(tenantId, ownerModule, recordRef)`, `open(tenantId, ownerModule, recordRef, id)` answering `{ contentType, bytes, filename, body }` or null for anything not readable (unknown, another pair, deleted, infected) and `delete(tenantId, ownerModule, recordRef, id)`; the reference pair is a scope, the caller's permission on its own record is the authorization, and the storage key never leaves documents.core. Checksums, scan verdicts and the object limits come from the storage port.
72
74
 
@@ -84,6 +84,12 @@ core:
84
84
  confirmation: restore-database
85
85
  supportsDryRun: true
86
86
  effect: 'replaces the configured database with the backup in <dir> (pg_restore --clean --if-exists, or a staged replacement of the embedded data directory); refuses BACKUP_ADAPTER_MISMATCH across adapters and warns BACKUP_KEY_MISMATCH when the running environment holds keys the backup was not taken with'
87
+ database.restore.production:
88
+ command: pnpm flowdular database restore-production --input <dir> --target <database> --grant <token> --tenant <id> [--allow-key-mismatch] [--platform-url <origin>|--platform-stopped] --apply --confirm restore-database
89
+ risk: destructive
90
+ confirmation: restore-database
91
+ supportsDryRun: true
92
+ effect: 'the same restore without localOnly, so it runs in any environment but only under a verified approval grant (see approvals.destructive-not-local; the grant binds --input, --target and every other capability flag); --target must repeat the database of the migrator DSN (the embedded data directory for pglite) or RESTORE_TARGET_MISMATCH; MIGRATOR_ROLE_REQUIRED when FD_DATABASE_MIGRATOR_URL is absent or equals FD_DATABASE_URL; a key divergence is a BACKUP_KEY_MISMATCH refusal unless --allow-key-mismatch is part of the approved invocation; with --apply, PLATFORM_RUNNING while <origin>/api/health (from --platform-url, else http://127.0.0.1:$FD_PORT) answers, and PLATFORM_STATE_UNKNOWN when no endpoint is named or the probe times out unless --platform-stopped attests the platform is down'
87
93
  workspace.state.migrate:
88
94
  command: pnpm flowdular setup migrate-state [--apply --confirm migrate-legacy-state]
89
95
  risk: destructive
@@ -146,6 +152,24 @@ modules:
146
152
  risk: process
147
153
  supportsDryRun: true
148
154
  note: re-seals stored trigger secrets with the current encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, and never prints a secret
155
+ documents.core:
156
+ documents.storage.rotate:
157
+ command: pnpm flowdular documents secrets-rotate [--apply]
158
+ risk: process
159
+ supportsDryRun: true
160
+ note: re-seals stored document objects in place with the current storage encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves an object under an unknown key or one that fails authentication as it is, and never prints object content
161
+ exports.core:
162
+ exports.storage.rotate:
163
+ command: pnpm flowdular exports secrets-rotate [--apply]
164
+ risk: process
165
+ supportsDryRun: true
166
+ note: re-seals stored export files in place with the current storage encryption key; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves a file under an unknown key or one that fails authentication as it is, and never prints file content
167
+ connectors.core:
168
+ connectors.secrets.rotate:
169
+ command: pnpm flowdular connectors secrets-rotate [--apply]
170
+ risk: process
171
+ supportsDryRun: true
172
+ note: re-seals stored connector credentials with the current encryption key and recomputes their fingerprints under it; batches of 200 rows in one tenant-scoped transaction each, idempotent, leaves a row under an unknown key as it is, and never prints a credential
149
173
  notifications.core:
150
174
  notifications.secrets.rotate:
151
175
  command: pnpm flowdular notifications secrets-rotate [--apply]
@@ -240,14 +264,18 @@ modules:
240
264
 
241
265
  # packages/cli/src/runner.ts, runExtensionCommand, in evaluation order.
242
266
  approvals:
243
- external: refused with APPROVAL_VERIFIER_REQUIRED (no signed approval verifier exists)
244
- destructive-not-local: refused with APPROVAL_VERIFIER_REQUIRED
267
+ external: the same grant check as destructive-not-local; APPROVAL_VERIFIER_REQUIRED without a token or key
268
+ destructive-not-local: needs --grant <token> --tenant <id>, an approval grant verified under FD_APPROVAL_GRANT_KEY against the tenant, the capability id and the invocation digest (positional arguments plus every flag except --root, --json, --confirm, --grant and --tenant, so --apply is bound and an approval names the applied run, never a dry run); APPROVAL_VERIFIER_REQUIRED without a token or key, APPROVAL_GRANT_INVALID, APPROVAL_GRANT_EXPIRED or APPROVAL_GRANT_MISMATCH otherwise; the runner records no use, so a grant replays until it expires
245
269
  localOnly: refused with LOCAL_ONLY_CAPABILITY unless FD_ENV or NODE_ENV is development or test (unset counts as development)
246
270
  requiresApprovedSpec: needs --spec <path>; APPROVED_SPEC_REQUIRED, SPEC_VALIDATION_FAILED or SPEC_NOT_APPROVED otherwise
247
271
  destructive-apply: --apply needs --confirm <descriptor.confirmation>; CONFIRMATION_REQUIRED otherwise
248
272
  non-read-without-dry-run: EXPLICIT_APPLY_REQUIRED without --apply
249
273
  dry-run: non-read with supportsDryRun and no --apply runs with apply=false and warns "Dry run only. No writes were authorized."
250
274
 
275
+ harnessGate:
276
+ localOnlyTools: CLI tools whose capability is localOnly are refused by the harness with TOOL_LOCAL_ONLY outside development and test, grant or not
277
+ grantedTools: external tools and non-local destructive CLI tools are offered only when the run carries a grant verified for the tool id and the input digest; one tool call per grant (APPROVAL_GRANT_CONSUMED afterwards); the consent gate still applies
278
+
251
279
  moduleExtensions:
252
280
  discovery: module.json cli.catalog (src/cli/commands.json), schema-validated without executing code
253
281
  loadImplementation: module.json cli.entry imported only when the command runs (packages/cli/src/extensions.ts loadCliCommand)
@@ -259,6 +287,5 @@ moduleExtensions:
259
287
  planned:
260
288
  - migration.plan (served by "migration apply" without --apply, which is a read-only dry run)
261
289
  - migration.apply.remote
262
- - destructive.execute (signed approval receipts)
263
290
  - redaction of *_TOKEN, *_SECRET, *_PASSWORD, DATABASE_URL in a shared logger (only packages/ai-provider/src/errors.ts redactSecrets exists)
264
291
  - persisted CLI audit (CommandEnvelope carries a random auditId and evidence per invocation; nothing stores it)
@@ -0,0 +1,3 @@
1
+ DROP INDEX IF EXISTS catalog_items_tenant_updated_idx;
2
+ DROP INDEX IF EXISTS catalog_items_tenant_name_idx;
3
+ ALTER TABLE catalog_items DROP COLUMN IF EXISTS updated_at;
@@ -0,0 +1,11 @@
1
+ ALTER TABLE catalog_items ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0;
2
+ -- The backfill runs as the migrator, which forced row security keeps out of
3
+ -- every row without a tenant setting; lift the flag for the statement only.
4
+ ALTER TABLE catalog_items NO FORCE ROW LEVEL SECURITY;
5
+ UPDATE catalog_items SET updated_at = created_at WHERE updated_at = 0;
6
+ ALTER TABLE catalog_items FORCE ROW LEVEL SECURITY;
7
+ ALTER TABLE catalog_items ALTER COLUMN updated_at DROP DEFAULT;
8
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_name_idx
9
+ ON catalog_items (tenant_id, lower(name), id);
10
+ CREATE INDEX IF NOT EXISTS catalog_items_tenant_updated_idx
11
+ ON catalog_items (tenant_id, updated_at, id);
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "catalog.core",
5
5
  "package": "@flowdular/module-catalog",
6
- "version": "0.7.0",
6
+ "version": "0.8.0",
7
7
  "profile": "full",
8
8
  "capabilities": ["api", "database", "client", "translations"],
9
9
  "platform": {
@@ -18,6 +18,16 @@
18
18
  {
19
19
  "id": "auth.core",
20
20
  "range": "^0.13.0"
21
+ },
22
+ {
23
+ "id": "exports.core",
24
+ "range": "^0.2.0"
25
+ }
26
+ ],
27
+ "requires": [
28
+ {
29
+ "id": "exports.lists.v1",
30
+ "optional": true
21
31
  }
22
32
  ],
23
33
  "tenancy": "required",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-catalog",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "octane": "0.1.51",
18
18
  "segment-state": "0.2.0",
19
- "@flowdular/sdk": "0.2.4"
19
+ "@flowdular/sdk": "0.3.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsrx/typescript-plugin": "0.3.120",
@@ -1,6 +1,6 @@
1
1
  schemaVersion: 1
2
2
  id: catalog.core
3
- specVersion: 0.7.0
3
+ specVersion: 0.8.0
4
4
  status: approved
5
5
  name: Product Catalog Core
6
6
  description: Provides tenant-scoped products and services with stable SKUs, units, prices, currencies, and lifecycle state.
@@ -15,6 +15,11 @@ dependencies:
15
15
  range: ^0.7.0
16
16
  - id: auth.core
17
17
  range: ^0.13.0
18
+ - id: exports.core
19
+ range: ^0.2.0
20
+ requires:
21
+ - id: exports.lists.v1
22
+ optional: true
18
23
  tenancy: required
19
24
  locales:
20
25
  - en
@@ -30,6 +35,10 @@ invariants:
30
35
  - One tenant-scoped durable target ledger is shared by mutating agent tools and workflow actions; each entry binds an idempotency key to the exact tool identifier or versioned workflow action identifier, canonical input digest, and first persisted result in the same transaction as the catalog mutation.
31
36
  - Replaying the same idempotency key for the same operation and canonical input returns the original result without another catalog item or history row, while reuse for another operation or input fails with a stable conflict.
32
37
  - Idempotency ledger entries remain after a catalog item is deleted, and audit metadata records identifiers, input digests, and outcomes without raw tool input, credentials, secrets, or result payloads.
38
+ - The items list is answered one page at a time on the shared signed cursor, at most 200 rows per read and 50 by default, ordered by lower-cased name, by normalized SKU or by the last update time, each order ending in the item id, narrowed in SQL by kind, by status and by a substring of the name or the SKU, and read as one index range over the matching (tenant_id, sort expression, id) index. A cursor is bound to the workspace, the sort, the direction and a digest of the filters, and a cursor of another listing or another workspace is refused with CURSOR_INVALID. No unbounded read of a workspace's items exists; the agent list tool and the dashboard widget read one bounded page.
39
+ - Every item carries the time of its last accepted mutation, set when it is created, edited, archived or restored, so the list can be ordered by recency.
40
+ - The items list export registered with exports.lists.v1 as catalog.core.items declares the SKU, name, kind, unit, base price in minor units, currency and status columns, is behind catalog.items.read, and is walked by exports.core under the principal that started the job through the same paged read the list endpoint answers, in SKU order. A deployment without exports.core still composes catalog.core and registers nothing.
41
+ - The bulk archive and bulk restore routes are siblings of the single-row lifecycle routes under the same permission and CSRF rule. Each names 1 to 100 unique item ids and executes the single-row path per id, so every accepted transition keeps its own history row and the idempotency ledger is untouched; an id that is missing or of another workspace answers not-found and a refused id answers refused with its stable code, and neither fails the other ids.
33
42
  - catalog.core declares three data classes to the platform registry while it composes and performs their export itself on its own lease under its own tenant transaction. catalog.core.items and catalog.core.history are master data, and each export walks every row of one workspace by keyset in bounded pages, and neither class carries a retention period, a sweep, or an erasure, so a row leaves only when a person deletes it. catalog.core.idempotency-ledger is operational, is never swept, and is excluded from the export with a stated reason because the item it points at is already exported.
34
43
  permissions:
35
44
  - id: catalog.items.read
@@ -37,15 +46,27 @@ permissions:
37
46
  - id: catalog.items.manage
38
47
  description: Create and update products and services in the active tenant.
39
48
  dataOwnership:
40
- - catalog.core owns SKU, item identity, kind, unit, base price, currency, and lifecycle status.
49
+ - catalog.core owns SKU, item identity, kind, unit, base price, currency, lifecycle status, and the last update time.
41
50
  - catalog.core owns the idempotency ledger for its mutating agent tools and workflow actions; callers supply the key but do not maintain a second target ledger.
42
51
  - catalog.core declares the data classes catalog.core.items (catalog_items), catalog.core.history (catalog_items_history_v2, which holds every row of the superseded catalog_items_history) and catalog.core.idempotency-ledger (catalog_idempotency_ledger); items and history are exported per workspace, the ledger is excluded with a stated reason.
43
52
  - Sales and purchasing modules reference catalog item identifiers and snapshot commercial terms when required by their specs.
44
53
  acceptanceScenarios:
45
54
  - id: CATALOG-LIST
46
55
  given: Tenant-scoped products and services exist.
47
- when: An authorized principal lists catalog items.
48
- then: Only items owned by the active tenant are returned in deterministic SKU order.
56
+ when: An authorized principal lists catalog items with a sort, a direction, filters and a page size.
57
+ then: Only items owned by the active tenant are returned, in the requested order ending in the item id, narrowed by kind, status and search in SQL, one page at a time with a cursor only on a full page, and two consecutive pages neither overlap nor leave a gap.
58
+ - id: CATALOG-LIST-CURSOR
59
+ given: A page cursor was issued for one listing of one workspace.
60
+ when: It is presented tampered, by another workspace, with other filters or with another sort, or an unknown sort is requested.
61
+ then: The server answers 400 with CURSOR_INVALID for the cursor and INVALID_INPUT for the sort, before any repository read.
62
+ - id: CATALOG-LIST-EXPORT
63
+ given: exports.core composed and registered its list capability.
64
+ when: catalog.core composes and a principal with catalog.items.read starts an export of catalog.core.items.
65
+ then: The catalogue lists the export with its declared columns and the job streams every item of the principal's workspace in SKU order, page by page, and no item of another workspace.
66
+ - id: CATALOG-BULK-LIFECYCLE
67
+ given: A principal with catalog.items.manage selects several items on the list.
68
+ when: The principal archives or restores the selection, naming a missing id and an id of another workspace beside existing ones.
69
+ then: Each existing item transitions with its own history row, the missing and foreign ids answer not-found, the call answers one outcome per id, the screen tells the counts, and a body without ids, with more than 100 ids, with a repeated id or without a valid CSRF token is refused.
49
70
  - id: CATALOG-CREATE
50
71
  given: An authorized principal supplies a unique SKU and valid product or service data.
51
72
  when: The catalog item is created.
@@ -8,7 +8,10 @@ import { agentActor, type Actor } from '@flowdular/sdk/kernel';
8
8
  import { CATALOG_PERMISSIONS } from '../acl/permissions.ts';
9
9
  import type { CatalogItemKind } from '../domain/types.ts';
10
10
  import type { CatalogRuntime } from '../server/runtime.ts';
11
- import { CatalogServiceError } from '../services/catalog-service.ts';
11
+ import {
12
+ CatalogServiceError,
13
+ FIRST_LIST_PAGE,
14
+ } from '../services/catalog-service.ts';
12
15
 
13
16
  const MAX_TOOL_ROWS = 200;
14
17
  const CREATE_OPERATION = 'catalog.item.create@1';
@@ -39,6 +42,7 @@ const CATALOG_ITEM_OUTPUT_SCHEMA = {
39
42
  currency: { type: 'string' },
40
43
  status: { type: 'string', enum: ['active', 'archived'] },
41
44
  createdAt: { type: 'integer' },
45
+ updatedAt: { type: 'integer' },
42
46
  },
43
47
  } as const;
44
48
 
@@ -91,15 +95,20 @@ export function catalogAgentTools(
91
95
  const query = normalized(
92
96
  (input as Record<string, unknown> | null)?.query,
93
97
  );
94
- return (await (await runtime.service()).list(context.tenantId))
95
- .filter(
96
- (item) =>
97
- query === '' ||
98
- item.id.toLocaleLowerCase('en-US') === query ||
99
- item.sku.toLocaleLowerCase('en-US').includes(query) ||
100
- item.name.toLocaleLowerCase('en-US').includes(query),
101
- )
102
- .slice(0, MAX_TOOL_ROWS);
98
+ const service = await runtime.service();
99
+ /* An exact id answers one row before any search, so the variable
100
+ resolver that binds an item id never reads a page. */
101
+ const exact =
102
+ query === '' ? null : await service.get(context.tenantId, query);
103
+ if (exact) return [exact];
104
+ return (
105
+ await service.listPage(context.tenantId, {
106
+ ...FIRST_LIST_PAGE,
107
+ sort: 'sku',
108
+ search: query,
109
+ limit: MAX_TOOL_ROWS,
110
+ })
111
+ ).items;
103
112
  },
104
113
  }),
105
114
  defineApiAgentTool({
@@ -1,9 +1,12 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import {
2
3
  defineEndpoint,
3
4
  HttpProblem,
4
5
  jsonResponse,
6
+ pageResponse,
5
7
  problemResponse,
6
8
  readJsonObject,
9
+ readPageQuery,
7
10
  requiredInteger,
8
11
  requiredString,
9
12
  } from '@flowdular/sdk/server';
@@ -16,13 +19,26 @@ import {
16
19
  sessionMutationDenial,
17
20
  } from '@flowdular/sdk/modules/auth/server';
18
21
  import { CATALOG_PERMISSIONS } from '../acl/permissions.ts';
19
- import type {
20
- CatalogItemKind,
21
- CreateCatalogItemInput,
22
- UpdateCatalogItemInput,
22
+ import {
23
+ CATALOG_BULK_LIMIT,
24
+ CATALOG_LIST_SORTS,
25
+ CATALOG_SEARCH_LENGTH,
26
+ type CatalogItemKind,
27
+ type CatalogListSort,
28
+ type CreateCatalogItemInput,
29
+ type UpdateCatalogItemInput,
23
30
  } from '../domain/types.ts';
24
- import { CatalogServiceError } from '../services/catalog-service.ts';
31
+ import {
32
+ CatalogServiceError,
33
+ LIST_PAGE_LIMIT,
34
+ LIST_PAGE_MAX_LIMIT,
35
+ } from '../services/catalog-service.ts';
25
36
  import type { CatalogRuntime } from '../server/runtime.ts';
37
+ import {
38
+ decodeListCursor,
39
+ encodeListCursor,
40
+ type CatalogListQuery,
41
+ } from './list-cursor.ts';
26
42
 
27
43
  function failure(error: unknown): Response {
28
44
  if (error instanceof CatalogServiceError) {
@@ -34,6 +50,59 @@ function failure(error: unknown): Response {
34
50
  return problemResponse(error, 'The catalog operation failed.');
35
51
  }
36
52
 
53
+ function invalid(message: string): HttpProblem {
54
+ return new HttpProblem('INVALID_INPUT', message, 400);
55
+ }
56
+
57
+ function listQuery(url: URL): CatalogListQuery {
58
+ const sort = url.searchParams.get('sort') ?? 'name';
59
+ if (!(CATALOG_LIST_SORTS as readonly string[]).includes(sort)) {
60
+ throw invalid(`sort must be one of ${CATALOG_LIST_SORTS.join(', ')}.`);
61
+ }
62
+ const direction = url.searchParams.get('direction') ?? 'asc';
63
+ if (direction !== 'asc' && direction !== 'desc') {
64
+ throw invalid('direction must be asc or desc.');
65
+ }
66
+ const kind = url.searchParams.get('kind') ?? '';
67
+ if (kind !== '' && kind !== 'product' && kind !== 'service') {
68
+ throw invalid('kind must be product or service.');
69
+ }
70
+ const status = url.searchParams.get('status') ?? '';
71
+ if (status !== '' && status !== 'active' && status !== 'archived') {
72
+ throw invalid('status must be active or archived.');
73
+ }
74
+ const search = (url.searchParams.get('q') ?? '').trim();
75
+ if (search.length > CATALOG_SEARCH_LENGTH) {
76
+ throw invalid(
77
+ `q must contain at most ${CATALOG_SEARCH_LENGTH} characters.`,
78
+ );
79
+ }
80
+ return {
81
+ sort: sort as CatalogListSort,
82
+ direction,
83
+ kind: kind === '' ? null : kind,
84
+ status: status === '' ? null : status,
85
+ search,
86
+ };
87
+ }
88
+
89
+ /* One outcome answers one row, so an id is named once; the count and each id
90
+ are bounded like the single route's. */
91
+ function requiredIds(value: Record<string, unknown>): readonly string[] {
92
+ const raw = value.ids;
93
+ if (!Array.isArray(raw)) throw invalid('ids must be an array.');
94
+ if (raw.length < 1 || raw.length > CATALOG_BULK_LIMIT) {
95
+ throw invalid(`ids must name between 1 and ${CATALOG_BULK_LIMIT} items.`);
96
+ }
97
+ const ids = raw.map((entry) =>
98
+ requiredString({ id: entry }, 'id', { max: 128 }),
99
+ );
100
+ if (new Set(ids).size !== ids.length) {
101
+ throw invalid('ids must not repeat an id.');
102
+ }
103
+ return ids;
104
+ }
105
+
37
106
  function mutableInput(
38
107
  value: Record<string, unknown>,
39
108
  ): Omit<UpdateCatalogItemInput, 'id'> {
@@ -58,18 +127,45 @@ export function createCatalogRoutes(
58
127
  auth: AuthRuntime,
59
128
  runtime: CatalogRuntime,
60
129
  ) {
130
+ /* Module-owned and never stored: a cursor names a position in one
131
+ workspace's own list, so a restart invalidating one costs a client the
132
+ first page. */
133
+ const cursorSecret = randomBytes(32);
61
134
  const list = defineEndpoint({
62
135
  id: 'catalog.items.list',
63
136
  path: '/api/catalog/items',
64
137
  methods: ['GET'],
65
138
  access: { kind: 'permission', permission: CATALOG_PERMISSIONS.read },
66
139
  resolveIdentity: endpointIdentityFromContext,
67
- handler: async ({ octane }) =>
68
- jsonResponse({
69
- items: await (
140
+ handler: async ({ octane }) => {
141
+ try {
142
+ const tenantId = principalFromContext(octane)!.tenantId;
143
+ const url = new URL(octane.request.url);
144
+ const page = readPageQuery(url, {
145
+ maxLimit: LIST_PAGE_MAX_LIMIT,
146
+ defaultLimit: LIST_PAGE_LIMIT,
147
+ });
148
+ const query = listQuery(url);
149
+ const result = await (
70
150
  await runtime.service()
71
- ).list(principalFromContext(octane)!.tenantId),
72
- }),
151
+ ).listPage(tenantId, {
152
+ ...query,
153
+ limit: page.limit,
154
+ after: page.cursor
155
+ ? decodeListCursor(cursorSecret, page.cursor, tenantId, query)
156
+ : null,
157
+ });
158
+ return pageResponse({
159
+ items: result.items,
160
+ limit: page.limit,
161
+ nextCursor: result.next
162
+ ? encodeListCursor(cursorSecret, tenantId, query, result.next)
163
+ : null,
164
+ });
165
+ } catch (error) {
166
+ return failure(error);
167
+ }
168
+ },
73
169
  });
74
170
  const create = defineEndpoint({
75
171
  id: 'catalog.items.create',
@@ -164,6 +260,46 @@ export function createCatalogRoutes(
164
260
  '/api/catalog/items/restore',
165
261
  'catalog.items.restore',
166
262
  );
263
+ /* The bulk sibling of a lifecycle route: same permission and CSRF rule,
264
+ one outcome per id through the same service path. */
265
+ const lifecycleMany = (
266
+ action: 'archiveMany' | 'restoreMany',
267
+ path: string,
268
+ id: string,
269
+ ) =>
270
+ defineEndpoint({
271
+ id,
272
+ path,
273
+ methods: ['POST'],
274
+ access: { kind: 'permission', permission: CATALOG_PERMISSIONS.manage },
275
+ resolveIdentity: endpointIdentityFromContext,
276
+ handler: async ({ octane }) => {
277
+ const denial = sessionMutationDenial(octane, auth);
278
+ if (denial) return denial;
279
+ try {
280
+ const value = await readJsonObject(octane.request);
281
+ const service = await runtime.service();
282
+ const outcomes = await service[action](
283
+ principalFromContext(octane)!.tenantId,
284
+ requiredIds(value),
285
+ actorFromContext(octane)!,
286
+ );
287
+ return jsonResponse({ outcomes });
288
+ } catch (error) {
289
+ return failure(error);
290
+ }
291
+ },
292
+ });
293
+ const archiveMany = lifecycleMany(
294
+ 'archiveMany',
295
+ '/api/catalog/items/archive-many',
296
+ 'catalog.items.archive-many',
297
+ );
298
+ const restoreMany = lifecycleMany(
299
+ 'restoreMany',
300
+ '/api/catalog/items/restore-many',
301
+ 'catalog.items.restore-many',
302
+ );
167
303
  const remove = defineEndpoint({
168
304
  id: 'catalog.items.delete',
169
305
  path: '/api/catalog/items/delete',
@@ -227,6 +363,8 @@ export function createCatalogRoutes(
227
363
  update.serverRoute,
228
364
  archive.serverRoute,
229
365
  restore.serverRoute,
366
+ archiveMany.serverRoute,
367
+ restoreMany.serverRoute,
230
368
  remove.serverRoute,
231
369
  history.serverRoute,
232
370
  ] as const;
@@ -238,6 +376,8 @@ export const endpoints = [
238
376
  'catalog.items.update',
239
377
  'catalog.items.archive',
240
378
  'catalog.items.restore',
379
+ 'catalog.items.archive-many',
380
+ 'catalog.items.restore-many',
241
381
  'catalog.items.delete',
242
382
  'catalog.items.history',
243
383
  ] as const;
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { decodeCursor, encodeCursor, HttpProblem } from '@flowdular/sdk/server';
3
+ import type {
4
+ CatalogItemKind,
5
+ CatalogItemStatus,
6
+ CatalogListSort,
7
+ } from '../domain/types.ts';
8
+ import type { CatalogPageKeyset } from '../services/repository.ts';
9
+
10
+ /** The listing a cursor belongs to: its order and its filters. */
11
+ export interface CatalogListQuery {
12
+ readonly sort: CatalogListSort;
13
+ readonly direction: 'asc' | 'desc';
14
+ readonly kind: CatalogItemKind | null;
15
+ readonly status: CatalogItemStatus | null;
16
+ readonly search: string;
17
+ }
18
+
19
+ function cursorInvalid(): HttpProblem {
20
+ return new HttpProblem(
21
+ 'CURSOR_INVALID',
22
+ 'The page cursor is not valid.',
23
+ 400,
24
+ );
25
+ }
26
+
27
+ /* The filters travel as a digest so a long term cannot push the signed cursor
28
+ past its length bound. */
29
+ function filtersDigest(query: CatalogListQuery): string {
30
+ return createHash('sha256')
31
+ .update(
32
+ JSON.stringify([query.kind ?? '', query.status ?? '', query.search]),
33
+ )
34
+ .digest('hex')
35
+ .slice(0, 32);
36
+ }
37
+
38
+ /* The cursor names the position and the listing it belongs to: the workspace,
39
+ the sort, the direction and the filters. A cursor presented with any of them
40
+ changed would splice two listings, so it is refused rather than reused. */
41
+ function cursorPayload(
42
+ tenantId: string,
43
+ query: CatalogListQuery,
44
+ keyset: CatalogPageKeyset,
45
+ ): Record<string, string> {
46
+ return {
47
+ t: tenantId,
48
+ s: query.sort,
49
+ d: query.direction,
50
+ f: filtersDigest(query),
51
+ v: keyset.sortValue,
52
+ id: keyset.id,
53
+ };
54
+ }
55
+
56
+ export function encodeListCursor(
57
+ secret: Uint8Array,
58
+ tenantId: string,
59
+ query: CatalogListQuery,
60
+ keyset: CatalogPageKeyset,
61
+ ): string {
62
+ return encodeCursor(cursorPayload(tenantId, query, keyset), secret);
63
+ }
64
+
65
+ /** Throws `HttpProblem` `CURSOR_INVALID` (400) for a cursor of another listing. */
66
+ export function decodeListCursor(
67
+ secret: Uint8Array,
68
+ cursor: string,
69
+ tenantId: string,
70
+ query: CatalogListQuery,
71
+ ): CatalogPageKeyset {
72
+ const payload = decodeCursor(cursor, secret);
73
+ const expected = cursorPayload(tenantId, query, { sortValue: '', id: '' });
74
+ for (const key of ['t', 's', 'd', 'f'] as const) {
75
+ if (payload[key] !== expected[key]) throw cursorInvalid();
76
+ }
77
+ const sortValue = payload.v;
78
+ const id = payload.id;
79
+ if (typeof sortValue !== 'string' || typeof id !== 'string') {
80
+ throw cursorInvalid();
81
+ }
82
+ return { sortValue, id };
83
+ }