@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.
- package/.ai/platform-capabilities.md +2 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/.ai/references/catalog/module.json +11 -1
- package/.ai/references/catalog/package.json +2 -2
- package/.ai/references/catalog/spec/module.yaml +25 -4
- package/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/.ai/references/catalog/src/client/api.ts +124 -36
- package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/.ai/references/catalog/src/client/state.ts +169 -3
- package/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/.ai/references/catalog/src/domain/types.ts +20 -0
- package/.ai/references/catalog/src/platform.ts +20 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/.ai/references/catalog/src/services/migration.ts +27 -1
- package/.ai/references/catalog/src/services/repository.ts +31 -2
- package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/.ai/references/catalog/tests/export.test.ts +134 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/.ai/references/catalog/tests/list.test.ts +217 -0
- package/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/.ai/references/catalog/tests/module.test.ts +2 -1
- package/.ai/references/catalog/tests/support/database.ts +14 -0
- package/.ai/references/catalog/translations/en.json +35 -4
- package/.ai/references/catalog/translations/pl.json +35 -4
- package/.ai/references/catalog.provenance.json +34 -26
- package/README.md +1 -1
- package/assets/flowdular-banner.webp +0 -0
- package/modules/access/module.json +1 -1
- package/modules/access/spec/module.yaml +1 -1
- package/modules/agents/module.json +1 -1
- package/modules/agents/spec/module.yaml +1 -1
- package/modules/approvals/module.json +1 -1
- package/modules/approvals/spec/module.yaml +1 -1
- package/modules/audit/module.json +1 -1
- package/modules/audit/spec/module.yaml +1 -1
- package/modules/auth/module.json +2 -2
- package/modules/auth/package.json +1 -1
- package/modules/auth/spec/module.yaml +2 -2
- package/modules/auth/src/middleware/authentication.ts +5 -1
- package/modules/auth/src/services/auth-service.ts +26 -0
- package/modules/auth/src/services/database-repository.ts +9 -0
- package/modules/auth/src/services/repository.ts +2 -0
- package/modules/automations/module.json +1 -1
- package/modules/automations/spec/module.yaml +1 -1
- package/modules/connectors/module.json +1 -1
- package/modules/connectors/spec/module.yaml +1 -1
- package/modules/directory/module.json +1 -1
- package/modules/directory/spec/module.yaml +1 -1
- package/modules/documents/module.json +1 -1
- package/modules/documents/spec/module.yaml +1 -1
- package/modules/exports/module.json +1 -1
- package/modules/exports/spec/module.yaml +1 -1
- package/modules/import/module.json +1 -1
- package/modules/import/spec/module.yaml +1 -1
- package/modules/metering/module.json +1 -1
- package/modules/metering/spec/module.yaml +1 -1
- package/modules/notifications/module.json +1 -1
- package/modules/notifications/spec/module.yaml +1 -1
- package/modules/reports/module.json +1 -1
- package/modules/reports/spec/module.yaml +1 -1
- package/modules/sandbox/module.json +1 -1
- package/modules/sandbox/spec/module.yaml +1 -1
- package/modules/search/module.json +1 -1
- package/modules/search/spec/module.yaml +1 -1
- package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
- package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
- package/modules/system/module.json +3 -2
- package/modules/system/package.json +2 -2
- package/modules/system/spec/module.yaml +98 -1
- package/modules/system/src/client/ModulesView.tsrx +101 -8
- package/modules/system/src/client/api.ts +52 -11
- package/modules/system/src/client/module-columns.tsrx +32 -6
- package/modules/system/src/client/state.ts +26 -0
- package/modules/system/src/domain/modules.ts +93 -0
- package/modules/system/src/index.ts +13 -0
- package/modules/system/src/platform.ts +41 -1
- package/modules/system/src/server/capability.ts +6 -0
- package/modules/system/src/server/endpoints.ts +125 -5
- package/modules/system/src/server/index.ts +26 -0
- package/modules/system/src/server/module-catalog.ts +19 -0
- package/modules/system/src/server/runtime.ts +102 -0
- package/modules/system/src/services/database-repository.ts +91 -0
- package/modules/system/src/services/migration.ts +39 -0
- package/modules/system/src/services/module-activation-service.ts +245 -0
- package/modules/system/src/services/repository.ts +17 -0
- package/modules/system/translations/en.json +17 -1
- package/modules/system/translations/pl.json +17 -1
- package/modules/users/module.json +2 -2
- package/modules/users/package.json +1 -1
- package/modules/users/spec/module.yaml +6 -9
- package/modules/users/src/client/UsersView.tsrx +27 -13
- package/modules/users/src/client/member-columns.tsrx +13 -4
- package/modules/users/src/client/state.ts +16 -0
- package/modules/users/src/services/users-service.ts +3 -0
- package/modules/users/translations/en.json +2 -1
- package/modules/users/translations/pl.json +2 -1
- package/modules/workflows/module.json +2 -2
- package/modules/workflows/package.json +1 -1
- package/modules/workflows/spec/module.yaml +2 -2
- package/modules/workflows/src/services/database-repository.ts +93 -53
- package/package.json +1 -1
- package/packages/client/package.json +1 -0
- package/packages/client/src/ApplicationShell.tsrx +46 -5
- package/packages/client/src/index.ts +5 -0
- package/packages/client/src/shell/modules.ts +49 -0
- package/packages/client/src/shell/types.ts +3 -0
- package/packages/contracts/src/index.ts +11 -1
- package/packages/server/src/endpoint.ts +17 -1
- package/packages/server/src/index.ts +10 -0
- package/packages/server/src/module-activation.ts +69 -0
- 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 -->
|
|
@@ -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.
|
|
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.
|
|
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.3.
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
).
|
|
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
|
+
}
|