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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +25 -0
  3. package/agent-template/.agents/skills/integration-adapter/SKILL.md +116 -0
  4. package/agent-template/.agents/skills/module-new/SKILL.md +17 -2
  5. package/agent-template/.agents/skills/module-update/SKILL.md +14 -2
  6. package/agent-template/.agents/skills/release-eject-pr/SKILL.md +23 -26
  7. package/agent-template/.agents/skills/spec-interview/SKILL.md +16 -0
  8. package/agent-template/.agents/skills/translations-i18n/SKILL.md +2 -1
  9. package/agent-template/.agents/skills/ux-design/SKILL.md +4 -4
  10. package/agent-template/.ai/agents/sandbox/backend-engineer.md +9 -0
  11. package/agent-template/.ai/blueprints/agentic-module/README.md +15 -0
  12. package/agent-template/.ai/blueprints/agentic-module/allowed-paths.yaml +14 -0
  13. package/agent-template/.ai/blueprints/agentic-module/blueprint.json +19 -0
  14. package/agent-template/.ai/blueprints/agentic-module/gates.yaml +24 -0
  15. package/agent-template/.ai/blueprints/agentic-module/input.schema.json +18 -0
  16. package/agent-template/.ai/blueprints/agentic-module/plan.schema.json +35 -0
  17. package/agent-template/.ai/blueprints/agentic-module/required-files.yaml +28 -0
  18. package/agent-template/.ai/blueprints/agentic-module/spec-requirements.yaml +33 -0
  19. package/agent-template/.ai/blueprints/agentic-module/steps.yaml +68 -0
  20. package/agent-template/.ai/platform-capabilities.md +16 -11
  21. package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  22. package/agent-template/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  23. package/agent-template/.ai/references/catalog/module.json +12 -2
  24. package/agent-template/.ai/references/catalog/package.json +2 -2
  25. package/agent-template/.ai/references/catalog/spec/module.yaml +26 -5
  26. package/agent-template/.ai/references/catalog/src/agent/tools.ts +19 -10
  27. package/agent-template/.ai/references/catalog/src/api/endpoints.ts +150 -10
  28. package/agent-template/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  29. package/agent-template/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  30. package/agent-template/.ai/references/catalog/src/client/api.ts +124 -36
  31. package/agent-template/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  32. package/agent-template/.ai/references/catalog/src/client/state.ts +169 -3
  33. package/agent-template/.ai/references/catalog/src/domain/lists.ts +7 -0
  34. package/agent-template/.ai/references/catalog/src/domain/types.ts +20 -0
  35. package/agent-template/.ai/references/catalog/src/platform.ts +20 -0
  36. package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  37. package/agent-template/.ai/references/catalog/src/services/database-repository.ts +104 -17
  38. package/agent-template/.ai/references/catalog/src/services/item-export.ts +81 -0
  39. package/agent-template/.ai/references/catalog/src/services/migration.ts +27 -1
  40. package/agent-template/.ai/references/catalog/src/services/repository.ts +31 -2
  41. package/agent-template/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  42. package/agent-template/.ai/references/catalog/tests/client-state.test.ts +124 -0
  43. package/agent-template/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  44. package/agent-template/.ai/references/catalog/tests/export.test.ts +134 -0
  45. package/agent-template/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  46. package/agent-template/.ai/references/catalog/tests/list.test.ts +217 -0
  47. package/agent-template/.ai/references/catalog/tests/migrations.test.ts +58 -2
  48. package/agent-template/.ai/references/catalog/tests/module.test.ts +2 -1
  49. package/agent-template/.ai/references/catalog/tests/support/database.ts +14 -0
  50. package/agent-template/.ai/references/catalog/translations/en.json +35 -4
  51. package/agent-template/.ai/references/catalog/translations/pl.json +35 -4
  52. package/agent-template/.ai/references/catalog.provenance.json +34 -26
  53. package/agent-template/.ai/rules/flowdular.md +2 -1
  54. package/agent-template/.ai/skills/README.md +1 -0
  55. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +25 -0
  56. package/agent-template/.ai/skills/integration-adapter/SKILL.md +121 -0
  57. package/agent-template/.ai/skills/module-new/SKILL.md +17 -2
  58. package/agent-template/.ai/skills/module-update/SKILL.md +14 -2
  59. package/agent-template/.ai/skills/release-eject-pr/SKILL.md +23 -26
  60. package/agent-template/.ai/skills/spec-interview/SKILL.md +16 -0
  61. package/agent-template/.ai/skills/translations-i18n/SKILL.md +2 -1
  62. package/agent-template/.ai/skills/ux-design/SKILL.md +4 -4
  63. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +25 -0
  64. package/agent-template/.claude/skills/integration-adapter/SKILL.md +116 -0
  65. package/agent-template/.claude/skills/module-new/SKILL.md +17 -2
  66. package/agent-template/.claude/skills/module-update/SKILL.md +14 -2
  67. package/agent-template/.claude/skills/release-eject-pr/SKILL.md +23 -26
  68. package/agent-template/.claude/skills/spec-interview/SKILL.md +16 -0
  69. package/agent-template/.claude/skills/translations-i18n/SKILL.md +2 -1
  70. package/agent-template/.claude/skills/ux-design/SKILL.md +4 -4
  71. package/agent-template/AGENTS.md +2 -1
  72. package/agent-template/CLAUDE.md +2 -1
  73. package/agent-template/docs/agent-contract.md +1 -1
  74. package/agent-template/docs/cli.md +8 -3
  75. package/agent-template/docs/configuration.md +29 -0
  76. package/agent-template/docs/design-system.md +112 -13
  77. package/agent-template/docs/module-distribution.md +10 -3
  78. package/agent-template/docs/modules.md +51 -1
  79. package/agent-template/docs/operations.md +2 -0
  80. package/agent-template/docs/sandbox.md +76 -1
  81. package/assets/flowdular-banner.webp +0 -0
  82. package/package.json +1 -1
  83. package/template/default/flowdular.json +2 -0
  84. package/template/default/modules/example/package.json +1 -1
  85. package/template/default/modules/example/src/client/NotesView.tsrx +12 -16
  86. package/template/default/modules/example/tests/module.test.ts +3 -2
  87. package/template/default/modules/example/translations/pl.json +3 -1
  88. package/template/default/package.json +1 -1
  89. package/template/default/platform/package.json +1 -1
  90. package/template/default/platform/src/generated/modules.client.ts +4 -0
  91. package/template/default/platform/src/generated/modules.server.ts +68 -8
  92. 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 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
+ }