@adula/kit 0.2.0-alpha.4 → 1.2.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 (120) hide show
  1. package/README.md +12 -2
  2. package/build/agent/AGENTS.template.md +2 -2
  3. package/build/agent/capabilities.md +54 -7
  4. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  5. package/build/agent/skills/idea-review/SKILL.md +26 -1
  6. package/build/agent/skills/module-review/SKILL.md +22 -0
  7. package/build/agent/skills/perf-review/SKILL.md +24 -0
  8. package/build/agent/skills/schema-review/SKILL.md +22 -0
  9. package/build/agent/skills/security-review/SKILL.md +24 -0
  10. package/build/agent/skills/ui-review/SKILL.md +22 -0
  11. package/build/commands/capabilities.d.ts +4 -0
  12. package/build/commands/capabilities.js +35 -4
  13. package/build/commands/doctor.js +37 -1
  14. package/build/commands/gaps.js +16 -6
  15. package/build/commands/install.js +19 -5
  16. package/build/commands/main.d.ts +4 -2
  17. package/build/commands/main.js +2 -0
  18. package/build/commands/module_add.js +2 -2
  19. package/build/commands/resource.js +1 -1
  20. package/build/commands/resource_snapshot.d.ts +15 -0
  21. package/build/commands/resource_snapshot.js +61 -0
  22. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  23. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  24. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  25. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  26. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  27. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  28. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  29. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  30. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  31. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  32. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  33. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  34. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  35. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  36. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  37. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  38. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  39. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  40. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  41. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  42. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  43. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  44. package/build/index.d.ts +31 -2
  45. package/build/index.js +17 -2
  46. package/build/src/admin/contracts.d.ts +7 -0
  47. package/build/src/admin/contracts.js +34 -12
  48. package/build/src/admin/controller.d.ts +2 -0
  49. package/build/src/admin/controller.js +52 -1
  50. package/build/src/admin/presentation.d.ts +6 -0
  51. package/build/src/admin/record_title.d.ts +14 -0
  52. package/build/src/admin/record_title.js +50 -0
  53. package/build/src/admin/resource_service.d.ts +184 -2
  54. package/build/src/admin/resource_service.js +713 -48
  55. package/build/src/attachments/attachment_service.d.ts +12 -0
  56. package/build/src/attachments/attachment_service.js +28 -2
  57. package/build/src/attachments/upload_grants.d.ts +44 -0
  58. package/build/src/attachments/upload_grants.js +105 -0
  59. package/build/src/auth/ability.d.ts +1 -1
  60. package/build/src/auth/ability.js +4 -1
  61. package/build/src/auth/actor_store.js +6 -1
  62. package/build/src/auth/conditions.d.ts +15 -0
  63. package/build/src/auth/conditions.js +36 -0
  64. package/build/src/auth/sql.js +6 -2
  65. package/build/src/collaboration/assignments.d.ts +129 -0
  66. package/build/src/collaboration/assignments.js +333 -0
  67. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  68. package/build/src/collaboration/record_collaboration.js +348 -0
  69. package/build/src/commands/agent_assets.js +5 -0
  70. package/build/src/commands/capabilities.d.ts +19 -0
  71. package/build/src/commands/capabilities.js +179 -0
  72. package/build/src/commands/doctor.d.ts +31 -0
  73. package/build/src/commands/doctor.js +114 -0
  74. package/build/src/commands/gap_report.d.ts +50 -2
  75. package/build/src/commands/gap_report.js +102 -4
  76. package/build/src/commands/generator.js +3 -3
  77. package/build/src/commands/snapshot.d.ts +28 -0
  78. package/build/src/commands/snapshot.js +48 -0
  79. package/build/src/commands/source_markers.d.ts +10 -1
  80. package/build/src/commands/source_markers.js +36 -4
  81. package/build/src/core/administration_guard.js +3 -1
  82. package/build/src/core/message_templates.d.ts +83 -0
  83. package/build/src/core/message_templates.js +293 -0
  84. package/build/src/core/module_seed.d.ts +15 -0
  85. package/build/src/core/module_seed.js +31 -0
  86. package/build/src/core/notifications.d.ts +18 -1
  87. package/build/src/core/notifications.js +27 -1
  88. package/build/src/core/roles.d.ts +27 -1
  89. package/build/src/core/roles.js +133 -5
  90. package/build/src/database/schema.d.ts +45 -0
  91. package/build/src/database/schema.js +290 -0
  92. package/build/src/eslint/index.js +26 -0
  93. package/build/src/events/outbox.d.ts +1 -0
  94. package/build/src/events/outbox.js +1 -1
  95. package/build/src/events/record_mutation.d.ts +11 -0
  96. package/build/src/events/record_mutation.js +15 -2
  97. package/build/src/integrations/imports.d.ts +74 -0
  98. package/build/src/integrations/imports.js +333 -0
  99. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  100. package/build/src/integrations/inbound_webhooks.js +276 -0
  101. package/build/src/integrations/openapi.d.ts +39 -0
  102. package/build/src/integrations/openapi.js +323 -0
  103. package/build/src/integrations/print.d.ts +37 -0
  104. package/build/src/integrations/print.js +124 -0
  105. package/build/src/integrations/webhooks.d.ts +98 -0
  106. package/build/src/integrations/webhooks.js +298 -0
  107. package/build/src/resource/define_resource.d.ts +1 -0
  108. package/build/src/resource/define_resource.js +21 -1
  109. package/build/src/resource/registry.d.ts +3 -0
  110. package/build/src/resource/registry.js +42 -0
  111. package/build/src/resource/types.d.ts +67 -1
  112. package/build/src/resource/values.js +2 -1
  113. package/build/src/services/settings.d.ts +13 -1
  114. package/build/src/services/settings.js +9 -2
  115. package/build/src/workflows/define_workflow.d.ts +124 -0
  116. package/build/src/workflows/define_workflow.js +123 -0
  117. package/build/src/workflows/engine.d.ts +141 -0
  118. package/build/src/workflows/engine.js +752 -0
  119. package/build/stubs/resource_contract.txt +103 -41
  120. package/package.json +7 -3
@@ -0,0 +1,348 @@
1
+ import { subject } from '@casl/ability';
2
+ import { jsonValue } from '../admin/contracts.js';
3
+ import { KitError } from '../admin/errors.js';
4
+ import { notifyWithTemplate } from '../core/message_templates.js';
5
+ const BODY_LIMIT = 5000;
6
+ const MENTION_LIMIT = 20;
7
+ const TAG_LIMIT = 20;
8
+ const TAG_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N} _-]{0,59}$/u;
9
+ /** Minimum permission level of a field for the current resource definition. */
10
+ function fieldLevel(resource, key) {
11
+ return Math.max(resource.fields[key]?.permissionLevel ?? 0, resource.hidden?.includes(key) ? 1 : 0);
12
+ }
13
+ function readableField(resource, record, ability, actor, key) {
14
+ const serialized = resource.serialize ?? [...resource.list, ...resource.show];
15
+ return (key in resource.fields &&
16
+ serialized.includes(key) &&
17
+ actor.permissionLevel >= fieldLevel(resource, key) &&
18
+ ability.can('view', subject(resource.name, record), key));
19
+ }
20
+ /**
21
+ * Collaboration around a record: comments with mentions, followers, tags and the
22
+ * per-field change history. Every read and write re-authorizes the record itself
23
+ * through ResourceService, so these features can never widen record access.
24
+ */
25
+ export class RecordCollaboration {
26
+ db;
27
+ resources;
28
+ actors;
29
+ constructor(db, resources, actors) {
30
+ this.db = db;
31
+ this.resources = resources;
32
+ this.actors = actors;
33
+ }
34
+ async state(name, id, actor) {
35
+ const { resource, record, ability } = await this.resources.access(name, id, actor);
36
+ const rows = await this.db('comments as c')
37
+ .leftJoin('users as u', 'u.id', 'c.author_id')
38
+ .where({ 'c.resource': name, 'c.record_id': id })
39
+ .whereNull('c.deleted_at')
40
+ .orderBy('c.id', 'desc')
41
+ .limit(51)
42
+ .select('c.*', 'u.full_name as author_name');
43
+ const page = rows.slice(0, 50);
44
+ const mentions = page.length
45
+ ? await this.db('comment_mentions as m')
46
+ .join('users as u', 'u.id', 'm.user_id')
47
+ .whereIn('m.comment_id', page.map((row) => row.id))
48
+ .select('m.comment_id', 'u.id', 'u.full_name')
49
+ : [];
50
+ const tags = await this.tags(name, id);
51
+ const follow = await this.db('followers')
52
+ .where({ resource: name, record_id: id })
53
+ .select(this.db.raw('count(*)::int as count'), this.db.raw('bool_or(user_id = ?) as own', [actor.id]))
54
+ .first();
55
+ return {
56
+ comments: page.reverse().map((row) => ({
57
+ id: Number(row.id),
58
+ body: String(row.body),
59
+ authorId: Number(row.author_id),
60
+ authorName: row.author_name ? String(row.author_name) : null,
61
+ mentions: mentions
62
+ .filter((mention) => Number(mention.comment_id) === Number(row.id))
63
+ .map((mention) => ({ id: Number(mention.id), name: String(mention.full_name ?? '') })),
64
+ createdAt: new Date(row.created_at).toISOString(),
65
+ editedAt: row.edited_at ? new Date(row.edited_at).toISOString() : null,
66
+ own: Number(row.author_id) === actor.id,
67
+ })),
68
+ hasMoreComments: rows.length > 50,
69
+ tags,
70
+ following: Boolean(follow?.own),
71
+ followers: Number(follow?.count ?? 0),
72
+ changes: await this.readChanges(resource, record, ability, actor, id),
73
+ canComment: true,
74
+ canTag: resource.actions.includes('update') &&
75
+ (await this.resources.permits(name, id, actor, 'update')),
76
+ };
77
+ }
78
+ async readChanges(resource, record, ability, actor, id) {
79
+ const rows = await this.db('field_changes as f')
80
+ .join('activities as a', 'a.id', 'f.activity_id')
81
+ .leftJoin('users as u', 'u.id', 'a.actor_id')
82
+ .where({ 'f.resource': resource.name, 'f.record_id': id })
83
+ .orderBy('f.id', 'desc')
84
+ .limit(200)
85
+ .select('f.*', 'a.created_at', 'u.full_name as actor_name');
86
+ // Values are filtered per viewer: a private field's history is as private as the field.
87
+ return rows
88
+ .filter((row) => readableField(resource, record, ability, actor, String(row.field)))
89
+ .slice(0, 100)
90
+ .map((row) => ({
91
+ id: Number(row.id),
92
+ field: String(row.field),
93
+ before: jsonValue(row.before),
94
+ after: jsonValue(row.after),
95
+ actorName: row.actor_name ? String(row.actor_name) : null,
96
+ createdAt: new Date(row.created_at).toISOString(),
97
+ }));
98
+ }
99
+ async comment(name, id, actor, input) {
100
+ await this.resources.access(name, id, actor);
101
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
102
+ if (!body || body.length > BODY_LIMIT)
103
+ throw new KitError(422, 'E_COMMENT_BODY', 'نص التعليق مطلوب ولا يتجاوز 5000 حرف');
104
+ const requested = this.userIds(input.mentions);
105
+ // Mentioning someone who cannot read the record would leak it through the notification.
106
+ const mentioned = [];
107
+ for (const userId of requested) {
108
+ if (userId === actor.id)
109
+ continue;
110
+ if (await this.canView(name, id, userId))
111
+ mentioned.push(userId);
112
+ else
113
+ throw new KitError(422, 'E_MENTION', 'لا يمكن الإشارة إلى مستخدم لا يملك صلاحية عرض السجل');
114
+ }
115
+ return this.db.transaction(async (trx) => {
116
+ const [row] = await trx('comments')
117
+ .insert({ resource: name, record_id: id, author_id: actor.id, body })
118
+ .returning('*');
119
+ if (mentioned.length)
120
+ await trx('comment_mentions').insert(mentioned.map((userId) => ({ comment_id: row.id, user_id: userId })));
121
+ await trx('followers')
122
+ .insert({ resource: name, record_id: id, user_id: actor.id })
123
+ .onConflict(['resource', 'record_id', 'user_id'])
124
+ .ignore();
125
+ const author = await trx('users').where('id', actor.id).first('full_name');
126
+ const authorName = author?.full_name ? String(author.full_name) : `مستخدم #${actor.id}`;
127
+ const label = this.label(name);
128
+ for (const userId of mentioned)
129
+ await notifyWithTemplate(trx, userId, 'comment.mentioned', { author: authorName, resource: label, id, excerpt: body.slice(0, 200) }, undefined, { resource: name, recordId: id });
130
+ const followers = await trx('followers')
131
+ .where({ resource: name, record_id: id })
132
+ .whereNot('user_id', actor.id)
133
+ .whereNotIn('user_id', mentioned.length ? mentioned : [0])
134
+ .pluck('user_id');
135
+ for (const userId of followers) {
136
+ if (!(await this.canView(name, id, Number(userId))))
137
+ continue;
138
+ await notifyWithTemplate(trx, Number(userId), 'comment.created', { author: authorName, resource: label, id, excerpt: body.slice(0, 200) }, undefined, { resource: name, recordId: id });
139
+ }
140
+ const names = mentioned.length
141
+ ? await trx('users').whereIn('id', mentioned).select('id', 'full_name')
142
+ : [];
143
+ return {
144
+ id: Number(row.id),
145
+ body,
146
+ authorId: actor.id,
147
+ authorName: author?.full_name ? String(author.full_name) : null,
148
+ mentions: names.map((user) => ({
149
+ id: Number(user.id),
150
+ name: String(user.full_name ?? ''),
151
+ })),
152
+ createdAt: new Date(row.created_at).toISOString(),
153
+ editedAt: null,
154
+ own: true,
155
+ };
156
+ });
157
+ }
158
+ /** Authors edit or remove their own comments while they can still read the record. */
159
+ async editComment(name, id, comment, actor, body) {
160
+ await this.resources.access(name, id, actor);
161
+ const commentId = this.commentId(comment);
162
+ const text = typeof body === 'string' ? body.trim() : '';
163
+ if (!text || text.length > BODY_LIMIT)
164
+ throw new KitError(422, 'E_COMMENT_BODY', 'نص التعليق مطلوب ولا يتجاوز 5000 حرف');
165
+ const updated = await this.db('comments')
166
+ .where({ id: commentId, resource: name, record_id: id, author_id: actor.id })
167
+ .whereNull('deleted_at')
168
+ .update({ body: text, edited_at: this.db.fn.now() });
169
+ if (!updated)
170
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
171
+ }
172
+ async deleteComment(name, id, comment, actor) {
173
+ await this.resources.access(name, id, actor);
174
+ const commentId = this.commentId(comment);
175
+ const deleted = await this.db('comments')
176
+ .where({ id: commentId, resource: name, record_id: id, author_id: actor.id })
177
+ .whereNull('deleted_at')
178
+ .update({ deleted_at: this.db.fn.now() });
179
+ if (!deleted)
180
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
181
+ }
182
+ async follow(name, id, actor, following) {
183
+ await this.resources.access(name, id, actor);
184
+ if (following)
185
+ await this.db('followers')
186
+ .insert({ resource: name, record_id: id, user_id: actor.id })
187
+ .onConflict(['resource', 'record_id', 'user_id'])
188
+ .ignore();
189
+ else
190
+ await this.db('followers').where({ resource: name, record_id: id, user_id: actor.id }).del();
191
+ }
192
+ /** Replaces the record's tags. Tagging changes how a record is found, so it needs update. */
193
+ async setTags(name, id, actor, input) {
194
+ const { resource } = await this.resources.access(name, id, actor);
195
+ if (!resource.actions.includes('update'))
196
+ throw new KitError(403, 'E_FORBIDDEN', 'ليس لديك صلاحية لهذا الإجراء');
197
+ await this.resources.access(name, id, actor, 'update');
198
+ if (!Array.isArray(input) || input.length > TAG_LIMIT)
199
+ throw new KitError(422, 'E_TAGS', 'الوسوم قائمة لا تتجاوز 20 وسماً');
200
+ const names = [
201
+ ...new Set(input.map((tag) => {
202
+ const value = typeof tag === 'string' ? tag.trim() : '';
203
+ if (!TAG_PATTERN.test(value))
204
+ throw new KitError(422, 'E_TAGS', 'الوسم حروف وأرقام ومسافات فقط ولا يتجاوز 60 حرفاً');
205
+ return value;
206
+ })),
207
+ ];
208
+ await this.db.transaction(async (trx) => {
209
+ if (names.length)
210
+ await trx('tags')
211
+ .insert(names.map((tag) => ({ name: tag })))
212
+ .onConflict('name')
213
+ .ignore();
214
+ const ids = names.length ? await trx('tags').whereIn('name', names).pluck('id') : [];
215
+ await trx('taggables').where({ resource: name, record_id: id }).del();
216
+ if (ids.length)
217
+ await trx('taggables').insert(ids.map((tagId) => ({ tag_id: tagId, resource: name, record_id: id })));
218
+ });
219
+ return this.tags(name, id);
220
+ }
221
+ async tags(name, id) {
222
+ const names = await this.db('taggables as t')
223
+ .join('tags', 'tags.id', 't.tag_id')
224
+ .where({ 't.resource': name, 't.record_id': id })
225
+ .orderBy('tags.name')
226
+ .pluck('tags.name');
227
+ return names.map(String);
228
+ }
229
+ /** Tag vocabulary used on records of a resource the actor may list. */
230
+ async tagOptions(name, actor) {
231
+ this.resources.describe(name, actor);
232
+ const names = await this.db('taggables as t')
233
+ .join('tags', 'tags.id', 't.tag_id')
234
+ .where('t.resource', name)
235
+ .distinct('tags.name')
236
+ .orderBy('tags.name')
237
+ .limit(200)
238
+ .pluck('tags.name');
239
+ return names.map(String);
240
+ }
241
+ /** Active users that may read the record, for the mention picker. */
242
+ async mentionCandidates(name, id, actor, search = '') {
243
+ await this.resources.access(name, id, actor);
244
+ const term = String(search).trim().slice(0, 60);
245
+ const result = [];
246
+ // Readers are filtered per user, so page through candidates until ten readers
247
+ // are found; a bounded scan keeps a large directory from turning into a sweep.
248
+ const page = 100;
249
+ for (let offset = 0; result.length < 10 && offset < 2000; offset += page) {
250
+ const query = this.db('users')
251
+ .whereNot('id', actor.id)
252
+ .whereNull('disabled_at')
253
+ .orderBy([{ column: 'full_name' }, { column: 'id' }])
254
+ .offset(offset)
255
+ .limit(page)
256
+ .select('id', 'full_name');
257
+ if (term)
258
+ query.where((where) => where.whereILike('full_name', `%${term}%`).orWhereILike('email', `${term}%`));
259
+ const users = await query;
260
+ for (const user of users) {
261
+ if (result.length >= 10)
262
+ break;
263
+ if (await this.canView(name, id, Number(user.id)))
264
+ result.push({ id: Number(user.id), name: String(user.full_name ?? `#${user.id}`) });
265
+ }
266
+ if (users.length < page)
267
+ break;
268
+ }
269
+ return result;
270
+ }
271
+ /**
272
+ * Listener factory: followers hear about updates and document transitions of the
273
+ * records they follow, but only while they can still read the record.
274
+ */
275
+ followerListener(module, resource, event) {
276
+ return {
277
+ name: `kit.followers.${module}.${resource}.${event}`,
278
+ event: `${module}.${resource}.${event}`,
279
+ handle: async (domainEvent, trx) => {
280
+ const id = Number(domainEvent.payload.id);
281
+ const actorId = Number(domainEvent.payload.actorId);
282
+ const followers = await trx('followers')
283
+ .where({ resource, record_id: id })
284
+ .whereNot('user_id', actorId)
285
+ .pluck('user_id');
286
+ const verbs = {
287
+ updated: 'تم تعديل',
288
+ submitted: 'تم اعتماد',
289
+ cancelled: 'تم إلغاء',
290
+ deleted: 'تم حذف',
291
+ };
292
+ for (const userId of followers) {
293
+ if (event !== 'deleted' && !(await this.canView(resource, id, Number(userId))))
294
+ continue;
295
+ await notifyWithTemplate(trx, Number(userId), 'record.changed', { change: verbs[event] ?? 'تحديث', resource: this.label(resource), id }, undefined,
296
+ // A deleted record has no page to open.
297
+ event === 'deleted' ? null : { resource, recordId: id });
298
+ }
299
+ },
300
+ };
301
+ }
302
+ label(name) {
303
+ try {
304
+ return this.resources.label(name);
305
+ }
306
+ catch {
307
+ return name;
308
+ }
309
+ }
310
+ async canView(name, id, userId) {
311
+ const disabled = await this.db('users').where('id', userId).first('disabled_at');
312
+ if (!disabled || disabled.disabled_at)
313
+ return false;
314
+ return this.resources.permits(name, id, await this.actors.load(userId));
315
+ }
316
+ /** Parsed after the record is authorized, so malformed ids never bypass the 403. */
317
+ commentId(value) {
318
+ const id = Number(value);
319
+ if (!Number.isSafeInteger(id) || id <= 0)
320
+ throw new KitError(404, 'E_COMMENT_NOT_FOUND', 'التعليق غير موجود');
321
+ return id;
322
+ }
323
+ userIds(value) {
324
+ if (value === undefined || value === null)
325
+ return [];
326
+ if (!Array.isArray(value) || value.length > MENTION_LIMIT)
327
+ throw new KitError(422, 'E_MENTION', 'الإشارات قائمة لا تتجاوز 20 مستخدماً');
328
+ return [
329
+ ...new Set(value.map((entry) => {
330
+ const id = Number(entry);
331
+ if (!Number.isSafeInteger(id) || id <= 0)
332
+ throw new KitError(422, 'E_MENTION', 'معرّف المستخدم غير صالح');
333
+ return id;
334
+ })),
335
+ ];
336
+ }
337
+ }
338
+ /** Follower notifications for every registered resource's update and document events. */
339
+ export function followerListeners(registry, collaboration) {
340
+ return registry.all().flatMap((resource) => ['updated', 'submitted', 'cancelled', 'deleted'].map((event) => {
341
+ const module = registry.owner(resource.name);
342
+ return {
343
+ name: `kit.followers.${module}.${resource.name}.${event}`,
344
+ event: `${module}.${resource.name}.${event}`,
345
+ handle: (domainEvent, trx) => collaboration().followerListener(module, resource.name, event).handle(domainEvent, trx),
346
+ };
347
+ }));
348
+ }
@@ -16,6 +16,11 @@ export async function agentAssets() {
16
16
  for (const [source, target] of [
17
17
  ['idea-review', 'adula-idea-review'],
18
18
  ['adula-frontend-design', 'adula-frontend-design'],
19
+ ['module-review', 'adula-module-review'],
20
+ ['security-review', 'adula-security-review'],
21
+ ['schema-review', 'adula-schema-review'],
22
+ ['ui-review', 'adula-ui-review'],
23
+ ['perf-review', 'adula-perf-review'],
19
24
  ]) {
20
25
  skills[`.agents/skills/${target}/SKILL.md`] = await readFile(new URL(`../../agent/skills/${source}/SKILL.md`, import.meta.url), 'utf8');
21
26
  }
@@ -0,0 +1,19 @@
1
+ import type { ResourceRegistry } from '../resource/registry.js';
2
+ /** Field kinds accepted by defineResource, with their storage. */
3
+ export declare const FIELD_TYPES: Record<string, string>;
4
+ export declare const FIELD_OPTIONS: string[];
5
+ export declare const RESOURCE_OPTIONS: string[];
6
+ export declare const CONDITION_OPERATORS: string[];
7
+ export declare const WORKFLOW_STEPS: string[];
8
+ /**
9
+ * Kit services by concern. Every name is a public export of @adula/kit; a test
10
+ * checks this list against the package entry so the catalog cannot drift.
11
+ */
12
+ export declare const SERVICES: Record<string, string[]>;
13
+ export declare const EXTENSION_POINTS: string[];
14
+ export declare const OUTSIDE_THE_KIT: string[];
15
+ /** The capability catalog read by idea-review: generated, never hand-edited. */
16
+ export declare function capabilityCatalog(options?: {
17
+ registry?: ResourceRegistry;
18
+ commands?: string[];
19
+ }): string;
@@ -0,0 +1,179 @@
1
+ import { KIT_VERSION } from '../version.js';
2
+ import { WORKFLOW_NAME_RULE } from '../workflows/define_workflow.js';
3
+ import { ACTOR_ID } from '../auth/conditions.js';
4
+ /** Field kinds accepted by defineResource, with their storage. */
5
+ export const FIELD_TYPES = {
6
+ string: 'varchar',
7
+ text: 'text',
8
+ integer: 'integer',
9
+ money: 'bigint minor units, decimal string in JSON',
10
+ boolean: 'boolean',
11
+ date: 'date (YYYY-MM-DD)',
12
+ datetime: 'timestamptz',
13
+ json: 'jsonb',
14
+ attachment: 'attachments row id; per-field accept/maxSize',
15
+ belongsTo: 'foreign key, preloaded, restrict on delete',
16
+ user: 'users foreign key, restrict on delete; choices are active members of the record unit or its ancestors; related as { id, fullName }',
17
+ hasMany: 'child resource; inline rows saved with the parent',
18
+ lookup: 'lookups group key',
19
+ };
20
+ export const FIELD_OPTIONS = [
21
+ 'required',
22
+ 'unique (partial, active rows)',
23
+ 'sortable',
24
+ 'searchable (generated tsvector)',
25
+ 'filterable',
26
+ 'permissionLevel',
27
+ 'sequence',
28
+ 'column',
29
+ ];
30
+ export const RESOURCE_OPTIONS = [
31
+ 'scoped (required)',
32
+ 'scope.from (the unit follows a required belongsTo parent)',
33
+ 'title (fields that name the record in relations, pickers, tasks and approvals)',
34
+ 'submittable (docStatus, submit/cancel/amend-by-copy)',
35
+ 'version (optimistic locking, default with submittable)',
36
+ 'customFields',
37
+ 'list / form / show / serialize / hidden',
38
+ 'actions',
39
+ 'hooks.beforeSave / hooks.afterSave',
40
+ ];
41
+ export const CONDITION_OPERATORS = ['$eq', '$ne', '$in', '$lt', '$gt', '$like'];
42
+ export const WORKFLOW_STEPS = [
43
+ 'condition',
44
+ 'update',
45
+ 'notify',
46
+ 'approval',
47
+ 'decision',
48
+ 'delay',
49
+ 'http',
50
+ 'end',
51
+ ];
52
+ /**
53
+ * Kit services by concern. Every name is a public export of @adula/kit; a test
54
+ * checks this list against the package entry so the catalog cannot drift.
55
+ */
56
+ export const SERVICES = {
57
+ 'Resources and authorization': [
58
+ 'defineResource',
59
+ 'ResourceRegistry',
60
+ 'ResourceService',
61
+ 'createResourceController',
62
+ 'buildAbility',
63
+ 'accessibleBy',
64
+ 'ActorStore',
65
+ 'packedResourceRules',
66
+ ],
67
+ 'Records and collaboration': [
68
+ 'RecordCollaboration',
69
+ 'followerListeners',
70
+ 'Assignments',
71
+ 'SavedViews',
72
+ 'logActivity',
73
+ ],
74
+ 'Documents and workflows': ['defineWorkflow', 'WorkflowEngine', 'workflowListeners'],
75
+ 'Notifications and messages': [
76
+ 'notify',
77
+ 'notifyWithTemplate',
78
+ 'MessageTemplates',
79
+ 'deliverNotificationMail',
80
+ 'listenForNotifications',
81
+ 'NotificationsAdmin',
82
+ ],
83
+ 'Integration': [
84
+ 'Webhooks',
85
+ 'signWebhook',
86
+ 'InboundWebhooks',
87
+ 'openApiDocument',
88
+ 'ImportBatches',
89
+ 'renderPrintHtml',
90
+ 'htmlToPdf',
91
+ ],
92
+ 'Security': ['UserInvitations', 'UsersAdmin', 'RolesAdmin'],
93
+ 'Data and operations': [
94
+ 'sequence',
95
+ 'Settings',
96
+ 'SettingsAdmin',
97
+ 'publishOutbox',
98
+ 'consumeEvent',
99
+ 'recordMutation',
100
+ 'moveOrgUnit',
101
+ 'migrateStorage',
102
+ 'verifyBackup',
103
+ 'runtimeHealth',
104
+ ],
105
+ };
106
+ export const EXTENSION_POINTS = [
107
+ 'Resource hooks (beforeSave, afterSave) inside the save transaction',
108
+ 'ResourceService.systemSave for module-decided writes (validator, hooks and audit, no role rules); ResourceService.rehome after a parent moves',
109
+ 'Assignment closing notes (Assignments closeNote: optional or required); managed assignments (managed: true) closed by module code with Assignments.close',
110
+ 'Page override: inertia/pages/<resource>/{index,form,show}.tsx replaces the generated page',
111
+ 'Create links with defaults: /resources/<resource>/create?defaults[field]=value',
112
+ 'Domain events <module>.<resource>.{created,updated,deleted,submitted,cancelled,amended} with idempotent listeners',
113
+ 'Module workflows (Module.workflows) with versioned definitions',
114
+ 'Message templates edited per deployment',
115
+ 'Outgoing webhooks and the bearer-token /api/v1 API',
116
+ 'Signed inbound webhooks raising inbound.<source>.<event> through the outbox',
117
+ ];
118
+ export const OUTSIDE_THE_KIT = [
119
+ 'Dynamic fields or a field editor screen',
120
+ 'Runtime plugins',
121
+ 'A visual workflow editor',
122
+ 'Multi-tenant SaaS in one database (one deployment per organization)',
123
+ 'XLSX import until a maintained parser passes the dependency rule (GAP-006)',
124
+ ];
125
+ /** The capability catalog read by idea-review: generated, never hand-edited. */
126
+ export function capabilityCatalog(options = {}) {
127
+ const lines = [
128
+ `# adula-kit capabilities (${KIT_VERSION})`,
129
+ '',
130
+ 'Generated by `node ace adula:capabilities`. Read this before proposing a module; anything not listed here is not provided by the kit.',
131
+ '',
132
+ '## Resource definition',
133
+ '',
134
+ '| Field type | Storage |',
135
+ '|---|---|',
136
+ ...Object.entries(FIELD_TYPES).map(([type, storage]) => `| ${type} | ${storage} |`),
137
+ '',
138
+ `Field options: ${FIELD_OPTIONS.join(', ')}.`,
139
+ '',
140
+ `Resource options: ${RESOURCE_OPTIONS.join(', ')}.`,
141
+ '',
142
+ `Role rule conditions: ${CONDITION_OPERATORS.join(', ')} on scalar fields; unsupported conditions are refused. Organization scope is always added with AND. \`${ACTOR_ID}\` names the signed-in user on user fields, createdBy and updatedBy ($eq, $ne, $in), bound per request.`,
143
+ '',
144
+ `Workflow steps: ${WORKFLOW_STEPS.join(', ')}. Workflow and step names: ${WORKFLOW_NAME_RULE} (for example release_approval, notify_approved).`,
145
+ '',
146
+ '## Services',
147
+ '',
148
+ ...Object.entries(SERVICES).map(([area, names]) => `- **${area}:** ${names.map((name) => `\`${name}\``).join(', ')}`),
149
+ '',
150
+ '## Extension points',
151
+ '',
152
+ ...EXTENSION_POINTS.map((point) => `- ${point}`),
153
+ '',
154
+ '## Outside the kit',
155
+ '',
156
+ ...OUTSIDE_THE_KIT.map((point) => `- ${point}`),
157
+ ];
158
+ if (options.commands?.length)
159
+ lines.push('', '## Commands', '', ...options.commands.map((command) => `- \`${command}\``));
160
+ if (options.registry) {
161
+ lines.push('', '## This project', '');
162
+ const modules = options.registry.modules();
163
+ if (!modules.length)
164
+ lines.push('No modules are registered yet.');
165
+ for (const module of modules) {
166
+ lines.push(`### ${module.name} — ${module.label.ar}`, '');
167
+ if (module.dependsOn.length)
168
+ lines.push(`Depends on: ${module.dependsOn.join(', ')}`, '');
169
+ for (const resource of module.resources)
170
+ lines.push(`- \`${resource.name}\` (${resource.label.ar}; ${resource.scoped ? 'scoped' : 'central'}${resource.submittable ? ', submittable' : ''}): ${Object.entries(resource.fields)
171
+ .map(([key, field]) => `${key}:${field.type}${'resource' in field ? `→${field.resource}` : ''}`)
172
+ .join(', ')}`);
173
+ for (const workflow of module.workflows ?? [])
174
+ lines.push(`- workflow \`${workflow.name}@${workflow.version}\` on ${workflow.resource}: ${Object.keys(workflow.steps).join(' → ')}`);
175
+ lines.push('');
176
+ }
177
+ }
178
+ return `${lines.join('\n').trimEnd()}\n`;
179
+ }
@@ -1,4 +1,7 @@
1
1
  import type { Settings } from '../services/settings.js';
2
+ import type { Field, Resource } from '../resource/types.js';
3
+ import type { WorkflowDefinition } from '../workflows/define_workflow.js';
4
+ import type { RuntimeHealth } from '../core/health.js';
2
5
  export type Finding = {
3
6
  check: string;
4
7
  status: 'pass' | 'warn' | 'fail' | 'info';
@@ -9,3 +12,31 @@ export declare function diagnoseUploads(root: string): Promise<Finding>;
9
12
  export declare function diagnoseUi(root: string): Promise<Finding[]>;
10
13
  export declare function diagnoseAgentSkills(root: string): Promise<Finding>;
11
14
  export declare function diagnose(root: string, settings: Settings, production: boolean, env: Record<string, string | undefined>, now?: number): Promise<Finding[]>;
15
+ type TableShape = Pick<Resource, 'name' | 'scoped' | 'version' | 'submittable' | 'customFields'> & {
16
+ fields: Record<string, Pick<Field, 'column' | 'required' | 'sequence' | 'unique' | 'searchable'> & {
17
+ type: string;
18
+ resource?: string;
19
+ }>;
20
+ };
21
+ /**
22
+ * A generated create-migration embeds the definition as it was at scaffold time. While it is
23
+ * still pending, compare that snapshot with the current resource so the table matches (#20).
24
+ */
25
+ export declare function diagnoseResourceSnapshots(pending: {
26
+ file: string;
27
+ source: string;
28
+ }[], resource: (name: string) => TableShape | undefined): Finding;
29
+ /**
30
+ * Workflow recipients address roles by their stable key, or for compatibility by
31
+ * their editable display name. List references that match no role, and those that
32
+ * only match a display name, which a rename in the roles screen would break (#25).
33
+ */
34
+ export declare function diagnoseWorkflowRoles(workflows: readonly Pick<WorkflowDefinition, 'name' | 'version' | 'steps'>[], roles: readonly (string | {
35
+ key: string | null;
36
+ name: string;
37
+ })[]): Finding;
38
+ /** Events wait in the outbox until a worker publishes them; a stale backlog means none runs (#50). */
39
+ export declare function diagnoseOutbox(health: Pick<RuntimeHealth, 'outbox'> & {
40
+ heartbeats: Pick<RuntimeHealth['heartbeats'], 'worker'>;
41
+ }): Finding;
42
+ export {};