@adula/kit 1.0.0 → 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 (87) hide show
  1. package/build/agent/AGENTS.template.md +2 -2
  2. package/build/agent/capabilities.md +10 -5
  3. package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
  4. package/build/commands/doctor.js +37 -1
  5. package/build/commands/gaps.js +16 -6
  6. package/build/commands/install.js +19 -5
  7. package/build/commands/main.js +2 -0
  8. package/build/commands/module_add.js +2 -2
  9. package/build/commands/resource.js +1 -1
  10. package/build/commands/resource_snapshot.d.ts +15 -0
  11. package/build/commands/resource_snapshot.js +61 -0
  12. package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
  13. package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
  14. package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
  15. package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
  16. package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
  17. package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
  18. package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
  19. package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
  20. package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
  22. package/build/index.d.ts +14 -5
  23. package/build/index.js +6 -2
  24. package/build/src/admin/contracts.d.ts +7 -0
  25. package/build/src/admin/contracts.js +15 -2
  26. package/build/src/admin/controller.d.ts +1 -0
  27. package/build/src/admin/controller.js +46 -1
  28. package/build/src/admin/presentation.d.ts +6 -0
  29. package/build/src/admin/record_title.d.ts +14 -0
  30. package/build/src/admin/record_title.js +50 -0
  31. package/build/src/admin/resource_service.d.ts +163 -2
  32. package/build/src/admin/resource_service.js +552 -43
  33. package/build/src/attachments/attachment_service.d.ts +12 -0
  34. package/build/src/attachments/attachment_service.js +28 -2
  35. package/build/src/attachments/upload_grants.d.ts +44 -0
  36. package/build/src/attachments/upload_grants.js +105 -0
  37. package/build/src/auth/ability.d.ts +1 -1
  38. package/build/src/auth/ability.js +4 -1
  39. package/build/src/auth/actor_store.js +6 -1
  40. package/build/src/auth/conditions.d.ts +15 -0
  41. package/build/src/auth/conditions.js +36 -0
  42. package/build/src/auth/sql.js +6 -2
  43. package/build/src/collaboration/assignments.d.ts +54 -3
  44. package/build/src/collaboration/assignments.js +125 -11
  45. package/build/src/collaboration/record_collaboration.js +5 -17
  46. package/build/src/commands/capabilities.js +22 -3
  47. package/build/src/commands/doctor.d.ts +31 -0
  48. package/build/src/commands/doctor.js +114 -0
  49. package/build/src/commands/gap_report.d.ts +50 -2
  50. package/build/src/commands/gap_report.js +102 -4
  51. package/build/src/commands/generator.js +3 -3
  52. package/build/src/commands/snapshot.d.ts +28 -0
  53. package/build/src/commands/snapshot.js +48 -0
  54. package/build/src/commands/source_markers.d.ts +10 -1
  55. package/build/src/commands/source_markers.js +36 -4
  56. package/build/src/core/administration_guard.js +3 -1
  57. package/build/src/core/message_templates.d.ts +4 -1
  58. package/build/src/core/message_templates.js +7 -2
  59. package/build/src/core/module_seed.d.ts +15 -0
  60. package/build/src/core/module_seed.js +31 -0
  61. package/build/src/core/notifications.d.ts +18 -1
  62. package/build/src/core/notifications.js +27 -1
  63. package/build/src/core/roles.d.ts +27 -1
  64. package/build/src/core/roles.js +133 -5
  65. package/build/src/database/schema.d.ts +27 -0
  66. package/build/src/database/schema.js +98 -0
  67. package/build/src/eslint/index.js +26 -0
  68. package/build/src/events/record_mutation.d.ts +4 -0
  69. package/build/src/events/record_mutation.js +2 -0
  70. package/build/src/integrations/imports.js +4 -2
  71. package/build/src/integrations/inbound_webhooks.d.ts +94 -0
  72. package/build/src/integrations/inbound_webhooks.js +276 -0
  73. package/build/src/integrations/openapi.js +69 -2
  74. package/build/src/integrations/print.js +1 -0
  75. package/build/src/resource/define_resource.js +12 -0
  76. package/build/src/resource/registry.d.ts +1 -0
  77. package/build/src/resource/registry.js +33 -0
  78. package/build/src/resource/types.d.ts +64 -1
  79. package/build/src/resource/values.js +2 -1
  80. package/build/src/services/settings.d.ts +13 -1
  81. package/build/src/services/settings.js +9 -2
  82. package/build/src/workflows/define_workflow.d.ts +30 -1
  83. package/build/src/workflows/define_workflow.js +44 -4
  84. package/build/src/workflows/engine.d.ts +24 -2
  85. package/build/src/workflows/engine.js +89 -24
  86. package/build/stubs/resource_contract.txt +103 -41
  87. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { conditionSql } from '../auth/sql.js';
2
+ import { resolveActorConditions, usesActor } from '../auth/conditions.js';
2
3
  import { KitError } from '../admin/errors.js';
3
4
  import { logActivity } from './activity.js';
4
5
  import { administrationGuard } from './administration_guard.js';
@@ -11,6 +12,7 @@ function standardConditionFields(resource) {
11
12
  label: { ar, en },
12
13
  type: 'integer',
13
14
  conditionable: true,
15
+ ...(['createdBy', 'updatedBy'].includes(key) ? { actor: true } : {}),
14
16
  });
15
17
  return [
16
18
  field('id', 'المعرّف', 'ID'),
@@ -27,6 +29,7 @@ function matrixFields(resource) {
27
29
  label: field.label,
28
30
  type: field.type,
29
31
  conditionable: !['hasMany', 'json', 'attachment'].includes(field.type),
32
+ ...(field.type === 'user' ? { actor: true } : {}),
30
33
  }));
31
34
  }
32
35
  function roleName(value) {
@@ -35,6 +38,13 @@ function roleName(value) {
35
38
  throw new KitError(422, 'E_ROLE_NAME', 'اسم الدور مطلوب (حتى 100 حرف)');
36
39
  return name;
37
40
  }
41
+ /** A role key: lower-case letters, digits and underscores, starting with a letter. */
42
+ export function roleKey(value) {
43
+ const key = typeof value === 'string' ? value.trim() : '';
44
+ if (!/^[a-z][a-z0-9_]{0,99}$/.test(key))
45
+ throw new KitError(422, 'E_ROLE_KEY', 'مفتاح الدور حروف إنجليزية صغيرة وأرقام وشرطة سفلية، ويبدأ بحرف (مثل project_manager)');
46
+ return key;
47
+ }
38
48
  function permissionLevel(value) {
39
49
  const level = Number(value ?? 0);
40
50
  if (!Number.isSafeInteger(level) || level < 0 || level > 9)
@@ -60,10 +70,11 @@ export class RolesAdmin {
60
70
  }
61
71
  async list() {
62
72
  const rows = await this.db('roles as r')
63
- .select('r.id', 'r.name', 'r.permission_level', this.db.raw('(SELECT count(*) FROM role_rules rr WHERE rr.role_id = r.id) AS rules'), this.db.raw('(SELECT count(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = r.id) AS users'))
73
+ .select('r.id', 'r.key', 'r.name', 'r.permission_level', this.db.raw('(SELECT count(*) FROM role_rules rr WHERE rr.role_id = r.id) AS rules'), this.db.raw('(SELECT count(DISTINCT ur.user_id) FROM user_roles ur WHERE ur.role_id = r.id) AS users'))
64
74
  .orderBy('r.name');
65
75
  return rows.map((row) => ({
66
76
  id: row.id,
77
+ key: row.key ?? null,
67
78
  name: row.name,
68
79
  permissionLevel: row.permission_level,
69
80
  rules: Number(row.rules),
@@ -81,6 +92,7 @@ export class RolesAdmin {
81
92
  .first();
82
93
  return {
83
94
  id: role.id,
95
+ key: role.key ?? null,
84
96
  name: role.name,
85
97
  permissionLevel: role.permission_level,
86
98
  users: Number(users?.count ?? 0),
@@ -89,19 +101,48 @@ export class RolesAdmin {
89
101
  }
90
102
  async create(actorId, input) {
91
103
  const name = roleName(input.name);
104
+ const key = input.key === undefined || input.key === null || input.key === '' ? null : roleKey(input.key);
92
105
  const level = permissionLevel(input.permissionLevel);
93
106
  return this.db.transaction(async (trx) => {
94
107
  if (await trx('roles').where('name', name).first())
95
108
  throw new KitError(409, 'E_ROLE_EXISTS', 'يوجد دور بهذا الاسم');
96
- const [role] = await trx('roles').insert({ name, permission_level: level }).returning('*');
109
+ if (key && (await trx('roles').where('key', key).first()))
110
+ throw new KitError(409, 'E_ROLE_KEY_EXISTS', 'يوجد دور بهذا المفتاح');
111
+ const [role] = await trx('roles')
112
+ .insert({ name, key, permission_level: level })
113
+ .returning('*');
97
114
  await logActivity(trx, {
98
115
  resource: RESOURCE,
99
116
  recordId: role.id,
100
117
  actorId,
101
118
  action: 'create',
102
- changes: { name, permissionLevel: level },
119
+ changes: { name, key, permissionLevel: level },
120
+ });
121
+ return { id: role.id, key, name, permissionLevel: level };
122
+ });
123
+ }
124
+ /**
125
+ * Gives a role its stable key. A key is set once: workflows and module defaults
126
+ * depend on it, so changing it would silently detach them.
127
+ */
128
+ async setKey(actorId, id, value) {
129
+ const key = roleKey(value);
130
+ return this.db.transaction(async (trx) => {
131
+ const role = await this.find(trx, id);
132
+ if (role.key === key)
133
+ return;
134
+ if (role.key)
135
+ throw new KitError(409, 'E_ROLE_KEY_FIXED', 'مفتاح الدور ثابت بعد تعيينه لأن تدفقات العمل والوحدات تعتمد عليه');
136
+ if (await trx('roles').where('key', key).whereNot('id', id).first())
137
+ throw new KitError(409, 'E_ROLE_KEY_EXISTS', 'يوجد دور بهذا المفتاح');
138
+ await trx('roles').where('id', id).update({ key });
139
+ await logActivity(trx, {
140
+ resource: RESOURCE,
141
+ recordId: id,
142
+ actorId,
143
+ action: 'set_key',
144
+ changes: { key },
103
145
  });
104
- return { id: role.id, name, permissionLevel: level };
105
146
  });
106
147
  }
107
148
  async rename(actorId, id, value) {
@@ -249,6 +290,81 @@ export class RolesAdmin {
249
290
  });
250
291
  });
251
292
  }
293
+ /**
294
+ * Creates module default roles that no role holds the key of yet, with their rules
295
+ * validated exactly like setRule. Existing roles are never modified. A keyless role
296
+ * with the same display name is adopted by receiving the key, rules untouched.
297
+ */
298
+ async ensureDefaults(actorId, roles) {
299
+ const prepared = roles.map((role) => {
300
+ const key = roleKey(role.key);
301
+ const name = roleName(role.name);
302
+ const level = permissionLevel(role.permissionLevel);
303
+ const rules = role.rules.map((rule) => {
304
+ try {
305
+ return this.validateRule(rule);
306
+ }
307
+ catch (error) {
308
+ throw new Error(`Default role ${key}: ${error.message}`);
309
+ }
310
+ });
311
+ return { key, name, level, rules };
312
+ });
313
+ const result = { created: [], adopted: [], kept: [] };
314
+ await this.db.transaction(async (trx) => {
315
+ for (const role of prepared) {
316
+ if (await trx('roles').where('key', role.key).first('id')) {
317
+ result.kept.push(role.key);
318
+ continue;
319
+ }
320
+ const named = await trx('roles').where('name', role.name).first();
321
+ if (named) {
322
+ // A role with this name but another key belongs to the administrator.
323
+ if (named.key) {
324
+ result.kept.push(role.key);
325
+ continue;
326
+ }
327
+ await trx('roles').where('id', named.id).update({ key: role.key });
328
+ await logActivity(trx, {
329
+ resource: RESOURCE,
330
+ recordId: named.id,
331
+ actorId,
332
+ action: 'set_key',
333
+ changes: { key: role.key, source: 'module default' },
334
+ });
335
+ result.adopted.push(role.key);
336
+ continue;
337
+ }
338
+ const [created] = await trx('roles')
339
+ .insert({ key: role.key, name: role.name, permission_level: role.level })
340
+ .returning('id');
341
+ for (const rule of role.rules)
342
+ await trx('role_rules').insert({
343
+ role_id: created.id,
344
+ subject: rule.subject,
345
+ action: rule.action,
346
+ inverted: rule.inverted,
347
+ conditions: rule.conditions ? JSON.stringify(rule.conditions) : null,
348
+ fields: rule.fields ? JSON.stringify(rule.fields) : null,
349
+ });
350
+ await logActivity(trx, {
351
+ resource: RESOURCE,
352
+ recordId: created.id,
353
+ actorId,
354
+ action: 'create',
355
+ changes: {
356
+ name: role.name,
357
+ key: role.key,
358
+ permissionLevel: role.level,
359
+ rules: role.rules.length,
360
+ source: 'module default',
361
+ },
362
+ });
363
+ result.created.push(role.key);
364
+ }
365
+ });
366
+ return result;
367
+ }
252
368
  validateRule(input) {
253
369
  const subject = typeof input.subject === 'string' ? input.subject : '';
254
370
  let resource;
@@ -287,8 +403,20 @@ export class RolesAdmin {
287
403
  for (const field of Object.keys(input.conditions))
288
404
  if (!conditionable.has(field))
289
405
  throw new KitError(422, 'E_RULE_CONDITIONS', `الحقل ${field} لا يقبل الشروط`);
406
+ const actorFields = new Set([...standardConditionFields(resource), ...matrixFields(resource)]
407
+ .filter((field) => field.actor)
408
+ .map((field) => field.key));
409
+ for (const [field, condition] of Object.entries(input.conditions))
410
+ if (usesActor({ [field]: condition })) {
411
+ if (!actorFields.has(field))
412
+ throw new KitError(422, 'E_RULE_CONDITIONS', `المستخدم الحالي يُستخدم مع حقول المستخدمين وأنشأه وعدّله فقط: ${field}`);
413
+ const operators = condition !== null && typeof condition === 'object' ? Object.keys(condition) : ['$eq'];
414
+ if (operators.some((operator) => !['$eq', '$ne', '$in'].includes(operator)))
415
+ throw new KitError(422, 'E_RULE_CONDITIONS', 'المستخدم الحالي يقبل عوامل يساوي ولا يساوي وضمن قائمة فقط');
416
+ }
290
417
  try {
291
- conditionSql(input.conditions, resource);
418
+ // The placeholder is an integer id at request time; validate it as one.
419
+ conditionSql(resolveActorConditions(input.conditions, 1), resource);
292
420
  }
293
421
  catch (error) {
294
422
  throw new KitError(422, 'E_RULE_CONDITIONS', `شرط غير مدعوم: ${error.message}`);
@@ -9,6 +9,11 @@ export declare function createResourceTable(db: Knex, resource: Pick<Resource, '
9
9
  export declare function createCollaborationSchema(db: Knex): Promise<void>;
10
10
  /** Work assigned to a user on a record; approval steps of workflows reuse it (phase 4). */
11
11
  export declare function createAssignmentsSchema(db: Knex): Promise<void>;
12
+ /**
13
+ * Managed assignments (1.1): opened and closed by module code with the record's state,
14
+ * never completed by hand. The close reason is shown with the closed task.
15
+ */
16
+ export declare function createManagedAssignmentsSchema(db: Knex): Promise<void>;
12
17
  /**
13
18
  * Message templates, notification e-mail delivery state and the realtime signal.
14
19
  * The trigger's NOTIFY is delivered only when the inserting transaction commits.
@@ -23,3 +28,25 @@ export declare function createImportsSchema(db: Knex): Promise<void>;
23
28
  * columns, and every transition is appended to workflow_events.
24
29
  */
25
30
  export declare function createWorkflowSchema(db: Knex): Promise<void>;
31
+ /**
32
+ * Roles gain a stable key (1.1) that workflows and module defaults address, so an
33
+ * administrator can rename the display name freely. Existing identifier-like names
34
+ * (such as the bootstrap "administrator") become their key.
35
+ */
36
+ export declare function createRoleKeysSchema(db: Knex): Promise<void>;
37
+ /**
38
+ * Notifications may name the record they are about (1.1). Links are authorized when
39
+ * opened, by the record page itself, never when the notification is written.
40
+ */
41
+ export declare function createNotificationTargetsSchema(db: Knex): Promise<void>;
42
+ /**
43
+ * Short-lived upload grants (1.1): module code lets one user upload to one attachment field
44
+ * of one record without a role rule. Only the SHA-256 hash of the token is stored.
45
+ */
46
+ export declare function createUploadGrantsSchema(db: Knex): Promise<void>;
47
+ /**
48
+ * Signed inbound webhooks (1.2): administrator-managed sources with sealed secrets,
49
+ * and a delivery log deduplicated by the sender's delivery id. Accepted deliveries are
50
+ * dispatched as domain events through the outbox.
51
+ */
52
+ export declare function createInboundWebhooksSchema(db: Knex): Promise<void>;
@@ -199,6 +199,10 @@ export async function createResourceTable(db, resource) {
199
199
  column = t.integer(name);
200
200
  column.references('id').inTable('attachments').onDelete('RESTRICT').index();
201
201
  break;
202
+ case 'user':
203
+ column = t.integer(name);
204
+ column.references('id').inTable('users').onDelete('RESTRICT').index();
205
+ break;
202
206
  case 'integer':
203
207
  column = t.integer(name);
204
208
  break;
@@ -329,6 +333,16 @@ export async function createAssignmentsSchema(db) {
329
333
  t.index(['workflow_run_id']);
330
334
  });
331
335
  }
336
+ /**
337
+ * Managed assignments (1.1): opened and closed by module code with the record's state,
338
+ * never completed by hand. The close reason is shown with the closed task.
339
+ */
340
+ export async function createManagedAssignmentsSchema(db) {
341
+ await db.schema.alterTable('assignments', (t) => {
342
+ t.boolean('managed').notNullable().defaultTo(false);
343
+ t.string('close_reason', 500);
344
+ });
345
+ }
332
346
  /**
333
347
  * Message templates, notification e-mail delivery state and the realtime signal.
334
348
  * The trigger's NOTIFY is delivered only when the inserting transaction commits.
@@ -438,3 +452,87 @@ export async function createWorkflowSchema(db) {
438
452
  t.index(['run_id', 'id']);
439
453
  });
440
454
  }
455
+ /**
456
+ * Roles gain a stable key (1.1) that workflows and module defaults address, so an
457
+ * administrator can rename the display name freely. Existing identifier-like names
458
+ * (such as the bootstrap "administrator") become their key.
459
+ */
460
+ export async function createRoleKeysSchema(db) {
461
+ await db.schema.alterTable('roles', (t) => {
462
+ t.string('key', 100).unique();
463
+ });
464
+ await db.raw("UPDATE roles SET key = name WHERE name ~ '^[a-z][a-z0-9_]*$'");
465
+ }
466
+ /**
467
+ * Notifications may name the record they are about (1.1). Links are authorized when
468
+ * opened, by the record page itself, never when the notification is written.
469
+ */
470
+ export async function createNotificationTargetsSchema(db) {
471
+ await db.schema.alterTable('notifications', (t) => {
472
+ t.string('resource', 100);
473
+ t.integer('record_id');
474
+ });
475
+ }
476
+ /**
477
+ * Short-lived upload grants (1.1): module code lets one user upload to one attachment field
478
+ * of one record without a role rule. Only the SHA-256 hash of the token is stored.
479
+ */
480
+ export async function createUploadGrantsSchema(db) {
481
+ await db.schema.createTable('upload_grants', (t) => {
482
+ t.increments('id');
483
+ t.string('token_hash', 64).notNullable().unique();
484
+ t.integer('user_id').notNullable().references('users.id').onDelete('CASCADE');
485
+ t.string('resource', 100).notNullable();
486
+ t.integer('record_id').notNullable();
487
+ t.string('field', 100).notNullable();
488
+ t.integer('org_unit_id');
489
+ t.timestamp('expires_at', { useTz: true }).notNullable();
490
+ t.integer('created_by').notNullable();
491
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
492
+ t.index(['expires_at']);
493
+ });
494
+ }
495
+ /**
496
+ * Signed inbound webhooks (1.2): administrator-managed sources with sealed secrets,
497
+ * and a delivery log deduplicated by the sender's delivery id. Accepted deliveries are
498
+ * dispatched as domain events through the outbox.
499
+ */
500
+ export async function createInboundWebhooksSchema(db) {
501
+ await db.schema.createTable('inbound_sources', (t) => {
502
+ t.increments('id');
503
+ t.string('key', 60).notNullable().unique();
504
+ t.string('name', 100).notNullable();
505
+ t.text('secret').notNullable();
506
+ t.string('algorithm', 10).notNullable().defaultTo('sha256');
507
+ t.string('signature_header', 100).notNullable().defaultTo('x-hub-signature-256');
508
+ t.string('signature_prefix', 20).notNullable().defaultTo('sha256=');
509
+ t.string('event_header', 100).notNullable().defaultTo('x-github-event');
510
+ t.string('delivery_header', 100).notNullable().defaultTo('x-github-delivery');
511
+ t.boolean('dedupe_body').notNullable().defaultTo(true);
512
+ t.boolean('active').notNullable().defaultTo(true);
513
+ t.integer('created_by').references('id').inTable('users').onDelete('SET NULL');
514
+ t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
515
+ t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
516
+ t.timestamp('last_received_at', { useTz: true });
517
+ });
518
+ await db.schema.createTable('inbound_deliveries', (t) => {
519
+ t.uuid('id').primary();
520
+ t.integer('source_id')
521
+ .notNullable()
522
+ .references('id')
523
+ .inTable('inbound_sources')
524
+ .onDelete('CASCADE');
525
+ t.string('delivery_id', 200).notNullable();
526
+ /** SHA-256 of the raw body while the source deduplicates bodies; null otherwise. */
527
+ t.string('body_key', 64);
528
+ t.string('event', 100).notNullable();
529
+ t.jsonb('payload').notNullable();
530
+ t.uuid('event_id').notNullable();
531
+ t.integer('dispatches').notNullable().defaultTo(1);
532
+ t.timestamp('received_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
533
+ t.unique(['source_id', 'delivery_id']);
534
+ t.unique(['source_id', 'body_key']);
535
+ t.index(['source_id', 'received_at']);
536
+ t.index(['received_at']);
537
+ });
538
+ }
@@ -42,6 +42,32 @@ export default {
42
42
  }
43
43
  },
44
44
  },
45
+ 'no-system-save-in-controllers': {
46
+ meta: {
47
+ type: 'problem',
48
+ schema: [],
49
+ messages: {
50
+ forbidden:
51
+ 'systemSave and rehome skip role rules. Call them from listeners or module services after an explicit authorization, never from a controller or route.',
52
+ },
53
+ },
54
+ create(context) {
55
+ const file = context.filename.replaceAll('\\', '/')
56
+ // Request handlers act for a user, so their writes go through save() and its rules.
57
+ if (!/(?:^|\/)(?:app\/controllers|app\/modules\/[^/]+\/controllers)\/|(?:^|\/)start\/routes\./.test(file))
58
+ return {}
59
+ return {
60
+ CallExpression(node) {
61
+ if (
62
+ node.callee.type === 'MemberExpression' &&
63
+ node.callee.property.type === 'Identifier' &&
64
+ ['systemSave', 'rehome'].includes(node.callee.property.name)
65
+ )
66
+ context.report({ node, messageId: 'forbidden' })
67
+ },
68
+ }
69
+ },
70
+ },
45
71
  'no-kit-patching': {
46
72
  meta: {
47
73
  type: 'problem',
@@ -17,4 +17,8 @@ export declare function recordMutation(trx: Knex.Transaction, mutation: {
17
17
  fields: string[];
18
18
  /** Before/after values of the changed fields; read back per viewer's field access. */
19
19
  changes?: FieldChange[];
20
+ /** Written by module code through ResourceService.systemSave, not by a user's edit. */
21
+ system?: boolean;
22
+ /** Why module code made the change; shown with the activity entry. */
23
+ reason?: string;
20
24
  }): Promise<void>;
@@ -28,6 +28,8 @@ export async function recordMutation(trx, mutation) {
28
28
  changes: JSON.stringify({
29
29
  fields: mutation.fields,
30
30
  ...(mutation.impersonatorId ? { impersonatedBy: mutation.impersonatorId } : {}),
31
+ ...(mutation.system ? { system: true } : {}),
32
+ ...(mutation.reason ? { reason: mutation.reason } : {}),
31
33
  }),
32
34
  })
33
35
  .returning('id');
@@ -20,7 +20,8 @@ export function importCell(field, raw, lookups) {
20
20
  const latin = value.replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
21
21
  switch (field.type) {
22
22
  case 'integer':
23
- case 'belongsTo': {
23
+ case 'belongsTo':
24
+ case 'user': {
24
25
  if (!/^-?\d+$/.test(latin))
25
26
  throw new Error('رقم صحيح مطلوب');
26
27
  return Number(latin);
@@ -108,7 +109,8 @@ export class ImportBatches {
108
109
  type: resource.fields[key].type,
109
110
  required: Boolean(resource.fields[key].required),
110
111
  }));
111
- if (resource.scoped)
112
+ // An inherited scope takes the unit from the parent row instead (#45).
113
+ if (resource.scoped && !resource.scope)
112
114
  fields.push({
113
115
  key: 'orgUnitId',
114
116
  label: 'الوحدة التنظيمية (رقم)',
@@ -0,0 +1,94 @@
1
+ import type { Knex } from 'knex';
2
+ import type { SecretBox } from './webhooks.js';
3
+ export type InboundSource = {
4
+ id: number;
5
+ key: string;
6
+ name: string;
7
+ algorithm: 'sha256' | 'sha512';
8
+ signatureHeader: string;
9
+ signaturePrefix: string;
10
+ eventHeader: string;
11
+ deliveryHeader: string;
12
+ /** Refuse a body already received from this source, whatever its delivery id or event. */
13
+ dedupeBody: boolean;
14
+ active: boolean;
15
+ createdAt: string;
16
+ lastReceivedAt: string | null;
17
+ /** The path senders post to. */
18
+ path: string;
19
+ };
20
+ export type InboundSourceInput = {
21
+ key?: unknown;
22
+ name?: unknown;
23
+ algorithm?: unknown;
24
+ signatureHeader?: unknown;
25
+ signaturePrefix?: unknown;
26
+ eventHeader?: unknown;
27
+ deliveryHeader?: unknown;
28
+ dedupeBody?: unknown;
29
+ active?: unknown;
30
+ };
31
+ export type InboundDelivery = {
32
+ id: string;
33
+ deliveryId: string;
34
+ event: string;
35
+ eventName: string;
36
+ dispatches: number;
37
+ receivedAt: string;
38
+ };
39
+ export type InboundReceipt = {
40
+ delivery: string;
41
+ event: string;
42
+ duplicate: boolean;
43
+ };
44
+ /** Inbound payloads above this size are refused before verification. */
45
+ export declare const INBOUND_BODY_LIMIT: number;
46
+ /** Stored deliveries older than this are removed by pruneDeliveries(). */
47
+ export declare const INBOUND_RETENTION_DAYS = 90;
48
+ /** The domain event an inbound delivery raises: inbound.<source>.<event>. */
49
+ export declare function inboundEventName(source: string, event: string): string;
50
+ /**
51
+ * Signed inbound webhooks (#30). Administrators create a source per sender (for example
52
+ * a Git host) and give it the generated secret. POST /webhooks/in/<key> verifies an
53
+ * HMAC over the raw body in constant time (X-Hub-Signature-256 by default), stores each
54
+ * delivery once by the sender's delivery id and raises inbound.<key>.<event> through the
55
+ * outbox, so module listeners run idempotently with retries and a delivery history.
56
+ */
57
+ export declare class InboundWebhooks {
58
+ private db;
59
+ private secrets;
60
+ constructor(db: Knex, secrets: SecretBox);
61
+ list(): Promise<InboundSource[]>;
62
+ /** Creates a source and returns its secret once; it is never shown again. */
63
+ create(actorId: number, input: InboundSourceInput): Promise<{
64
+ source: InboundSource;
65
+ secret: string;
66
+ }>;
67
+ /**
68
+ * Updates the fields given (name, algorithm, headers, body deduplication, state); fields
69
+ * left out keep their stored values. The key and secret stay.
70
+ */
71
+ update(id: number, input: InboundSourceInput): Promise<void>;
72
+ /** Issues a new secret; the old one stops verifying at once. */
73
+ rotate(id: number): Promise<string>;
74
+ remove(id: number): Promise<void>;
75
+ /**
76
+ * Verifies and records one delivery. Unknown or inactive sources and bad signatures
77
+ * are refused without storing anything; a repeated delivery id is acknowledged once.
78
+ */
79
+ receive(key: string, request: {
80
+ headers: Record<string, string | string[] | undefined>;
81
+ body: string | Buffer;
82
+ }): Promise<InboundReceipt>;
83
+ deliveries(sourceId: number, limit?: number): Promise<InboundDelivery[]>;
84
+ /**
85
+ * Raises a stored delivery again as a new domain event, for listeners that failed or
86
+ * were added later. Listeners must be idempotent on the delivery id.
87
+ */
88
+ redispatch(deliveryId: string): Promise<void>;
89
+ /** Removes stored deliveries older than the retention period (90 days by default). */
90
+ pruneDeliveries(days?: number): Promise<number>;
91
+ private envelope;
92
+ private validate;
93
+ private present;
94
+ }