create-flowdular 0.3.1 → 0.4.0
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/README.md +1 -1
- package/agent-template/.agents/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.agents/skills/integration-adapter/SKILL.md +116 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +17 -2
- package/agent-template/.agents/skills/module-update/SKILL.md +14 -2
- package/agent-template/.agents/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.agents/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.agents/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.agents/skills/ux-design/SKILL.md +4 -4
- package/agent-template/.ai/agents/sandbox/backend-engineer.md +9 -0
- package/agent-template/.ai/blueprints/agentic-module/README.md +15 -0
- package/agent-template/.ai/blueprints/agentic-module/allowed-paths.yaml +14 -0
- package/agent-template/.ai/blueprints/agentic-module/blueprint.json +19 -0
- package/agent-template/.ai/blueprints/agentic-module/gates.yaml +24 -0
- package/agent-template/.ai/blueprints/agentic-module/input.schema.json +18 -0
- package/agent-template/.ai/blueprints/agentic-module/plan.schema.json +35 -0
- package/agent-template/.ai/blueprints/agentic-module/required-files.yaml +28 -0
- package/agent-template/.ai/blueprints/agentic-module/spec-requirements.yaml +33 -0
- package/agent-template/.ai/blueprints/agentic-module/steps.yaml +68 -0
- package/agent-template/.ai/platform-capabilities.md +16 -11
- package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
- package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
- package/agent-template/.ai/references/catalog/module.json +12 -2
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +26 -5
- package/agent-template/.ai/references/catalog/src/agent/tools.ts +19 -10
- package/agent-template/.ai/references/catalog/src/api/endpoints.ts +150 -10
- package/agent-template/.ai/references/catalog/src/api/list-cursor.ts +83 -0
- package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
- package/agent-template/.ai/references/catalog/src/client/api.ts +124 -36
- package/agent-template/.ai/references/catalog/src/client/contribution.tsrx +5 -0
- package/agent-template/.ai/references/catalog/src/client/state.ts +169 -3
- package/agent-template/.ai/references/catalog/src/domain/lists.ts +7 -0
- package/agent-template/.ai/references/catalog/src/domain/types.ts +20 -0
- package/agent-template/.ai/references/catalog/src/platform.ts +20 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +143 -8
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +104 -17
- package/agent-template/.ai/references/catalog/src/services/item-export.ts +81 -0
- package/agent-template/.ai/references/catalog/src/services/migration.ts +27 -1
- package/agent-template/.ai/references/catalog/src/services/repository.ts +31 -2
- package/agent-template/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
- package/agent-template/.ai/references/catalog/tests/client-state.test.ts +124 -0
- package/agent-template/.ai/references/catalog/tests/endpoints.test.ts +269 -0
- package/agent-template/.ai/references/catalog/tests/export.test.ts +134 -0
- package/agent-template/.ai/references/catalog/tests/idempotency.test.ts +15 -14
- package/agent-template/.ai/references/catalog/tests/list.test.ts +217 -0
- package/agent-template/.ai/references/catalog/tests/migrations.test.ts +58 -2
- package/agent-template/.ai/references/catalog/tests/module.test.ts +2 -1
- package/agent-template/.ai/references/catalog/tests/support/database.ts +14 -0
- package/agent-template/.ai/references/catalog/translations/en.json +35 -4
- package/agent-template/.ai/references/catalog/translations/pl.json +35 -4
- package/agent-template/.ai/references/catalog.provenance.json +34 -26
- package/agent-template/.ai/rules/flowdular.md +2 -1
- package/agent-template/.ai/skills/README.md +1 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.ai/skills/integration-adapter/SKILL.md +121 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +17 -2
- package/agent-template/.ai/skills/module-update/SKILL.md +14 -2
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.ai/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.ai/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.ai/skills/ux-design/SKILL.md +4 -4
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +25 -0
- package/agent-template/.claude/skills/integration-adapter/SKILL.md +116 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +17 -2
- package/agent-template/.claude/skills/module-update/SKILL.md +14 -2
- package/agent-template/.claude/skills/release-eject-pr/SKILL.md +23 -26
- package/agent-template/.claude/skills/spec-interview/SKILL.md +16 -0
- package/agent-template/.claude/skills/translations-i18n/SKILL.md +2 -1
- package/agent-template/.claude/skills/ux-design/SKILL.md +4 -4
- package/agent-template/AGENTS.md +2 -1
- package/agent-template/CLAUDE.md +2 -1
- package/agent-template/docs/agent-contract.md +1 -1
- package/agent-template/docs/cli.md +8 -3
- package/agent-template/docs/configuration.md +29 -0
- package/agent-template/docs/design-system.md +112 -13
- package/agent-template/docs/module-distribution.md +10 -3
- package/agent-template/docs/modules.md +51 -1
- package/agent-template/docs/operations.md +2 -0
- package/agent-template/docs/sandbox.md +76 -1
- package/assets/flowdular-banner.webp +0 -0
- package/package.json +1 -1
- package/template/default/flowdular.json +2 -0
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/src/client/NotesView.tsrx +12 -16
- package/template/default/modules/example/tests/module.test.ts +3 -2
- package/template/default/modules/example/translations/pl.json +3 -1
- package/template/default/package.json +1 -1
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/src/generated/modules.client.ts +4 -0
- package/template/default/platform/src/generated/modules.server.ts +68 -8
- package/assets/flowdular-banner.png +0 -0
|
@@ -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
|
+
}
|