@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
@@ -12,11 +12,23 @@ export type StepContext = {
12
12
  };
13
13
  db: Knex;
14
14
  };
15
- export type Recipients = 'creator' | 'submitter' | {
15
+ export type Recipients = 'creator' | 'submitter'
16
+ /**
17
+ * The members of a role, addressed by its stable key (roles.key). A role without a
18
+ * key is still found by its display name, which administrators can rename.
19
+ */
20
+ | {
16
21
  role: string;
17
22
  } | {
18
23
  users: number[];
19
24
  } | ((context: StepContext) => number[] | Promise<number[]>);
25
+ /** One decision an approver can take at a decision step (#42). */
26
+ export type DecisionOutcome = {
27
+ label: string;
28
+ next: string;
29
+ /** Whether the decision needs a comment; defaults to optional. */
30
+ comment?: 'optional' | 'required';
31
+ };
20
32
  export type WorkflowStep = {
21
33
  type: 'condition';
22
34
  label?: string;
@@ -43,6 +55,17 @@ export type WorkflowStep = {
43
55
  dueInDays?: number;
44
56
  approve: string;
45
57
  reject: string;
58
+ } | {
59
+ /**
60
+ * An approval with named outcomes, for example approve, reject and return. Each
61
+ * outcome names its next step (a later or an earlier one) and may require a comment.
62
+ * The submitted document stays locked: a decision never changes its fields.
63
+ */
64
+ type: 'decision';
65
+ label: string;
66
+ assignees: Recipients;
67
+ dueInDays?: number;
68
+ outcomes: Record<string, DecisionOutcome>;
46
69
  } | {
47
70
  type: 'delay';
48
71
  label?: string;
@@ -62,11 +85,13 @@ export type WorkflowStep = {
62
85
  cancelDocument?: boolean;
63
86
  };
64
87
  export type WorkflowInput = {
88
+ /** Lower-case letters, digits and underscores, starting with a letter (for example release_approval). */
65
89
  name: string;
66
90
  version: number;
67
91
  resource: string;
68
92
  label: string;
69
93
  start: string;
94
+ /** Step names follow the workflow name rule (for example notify_approved). */
70
95
  steps: Record<string, WorkflowStep>;
71
96
  /** Failed step attempts before the run stops as failed (default 5). */
72
97
  maxAttempts?: number;
@@ -83,7 +108,11 @@ export type WorkflowEvent = {
83
108
  type: 'APPROVE';
84
109
  } | {
85
110
  type: 'REJECT';
111
+ } | {
112
+ type: 'DECIDE';
113
+ outcome: string;
86
114
  };
115
+ export declare const WORKFLOW_NAME_RULE = "use lower-case letters, digits and underscores, starting with a letter";
87
116
  /**
88
117
  * Declares a versioned workflow. Steps compile to an XState machine; the engine
89
118
  * performs each step's effect and asks the machine for the next step. Runs keep
@@ -1,11 +1,30 @@
1
1
  import { createMachine, getNextSnapshot } from 'xstate';
2
2
  const IDENTIFIER = /^[a-z][a-z0-9_]*$/;
3
+ export const WORKFLOW_NAME_RULE = 'use lower-case letters, digits and underscores, starting with a letter';
4
+ /** Names the rule and a valid spelling, since dotted event-style names are a natural first try. */
5
+ function assertIdentifier(kind, value) {
6
+ if (IDENTIFIER.test(value))
7
+ return;
8
+ const suggestion = value
9
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '_')
12
+ .replace(/^[^a-z]+|_+$/g, '');
13
+ const example = IDENTIFIER.test(suggestion)
14
+ ? suggestion
15
+ : kind === 'workflow'
16
+ ? 'release_approval'
17
+ : 'notify_approved';
18
+ throw new Error(`Invalid ${kind} name "${value}": ${WORKFLOW_NAME_RULE} (for example ${example}).`);
19
+ }
3
20
  function targets(step) {
4
21
  switch (step.type) {
5
22
  case 'condition':
6
23
  return [step.then, step.else];
7
24
  case 'approval':
8
25
  return [step.approve, step.reject];
26
+ case 'decision':
27
+ return Object.values(step.outcomes).map((outcome) => outcome.next);
9
28
  case 'end':
10
29
  return [];
11
30
  default:
@@ -19,22 +38,31 @@ function targets(step) {
19
38
  * running workflow (migrate explicitly with WorkflowEngine.migrateRuns).
20
39
  */
21
40
  export function defineWorkflow(input) {
22
- if (!IDENTIFIER.test(input.name))
23
- throw new Error(`Invalid workflow name: ${input.name}`);
41
+ assertIdentifier('workflow', input.name);
24
42
  if (!Number.isInteger(input.version) || input.version < 1)
25
43
  throw new Error('Workflow version must be a positive integer');
26
44
  if (!(input.start in input.steps))
27
45
  throw new Error(`Unknown start step: ${input.start}`);
28
46
  const keys = Object.keys(input.steps);
29
47
  for (const key of keys) {
30
- if (!IDENTIFIER.test(key))
31
- throw new Error(`Invalid step name: ${key}`);
48
+ assertIdentifier('step', key);
32
49
  for (const target of targets(input.steps[key]))
33
50
  if (!(target in input.steps))
34
51
  throw new Error(`Step ${key} points to unknown step ${target}`);
35
52
  const step = input.steps[key];
36
53
  if (step.type === 'delay' && (!Number.isInteger(step.ms) || step.ms < 0))
37
54
  throw new Error(`Step ${key} needs a non-negative integer delay`);
55
+ if (step.type === 'decision') {
56
+ const outcomes = Object.entries(step.outcomes ?? {});
57
+ if (outcomes.length < 2)
58
+ throw new Error(`Decision step ${key} needs at least two outcomes`);
59
+ for (const [name, outcome] of outcomes) {
60
+ if (!IDENTIFIER.test(name) || name.length > 20)
61
+ throw new Error(`Invalid outcome name "${name}" in step ${key}: ${WORKFLOW_NAME_RULE}, at most 20 characters`);
62
+ if (!outcome.label)
63
+ throw new Error(`Outcome ${name} of step ${key} needs a label`);
64
+ }
65
+ }
38
66
  }
39
67
  if (!keys.some((key) => input.steps[key].type === 'end'))
40
68
  throw new Error('A workflow needs at least one end step');
@@ -61,6 +89,18 @@ export function defineWorkflow(input) {
61
89
  ];
62
90
  case 'approval':
63
91
  return [key, { on: { APPROVE: step.approve, REJECT: step.reject } }];
92
+ case 'decision':
93
+ return [
94
+ key,
95
+ {
96
+ on: {
97
+ DECIDE: Object.entries(step.outcomes).map(([name, outcome]) => ({
98
+ target: outcome.next,
99
+ guard: ({ event }) => event.type === 'DECIDE' && event.outcome === name,
100
+ })),
101
+ },
102
+ },
103
+ ];
64
104
  case 'end':
65
105
  return [key, { type: 'final' }];
66
106
  default:
@@ -13,6 +13,8 @@ export type WorkflowRun = {
13
13
  resource: string;
14
14
  resourceLabel: string;
15
15
  recordId: number;
16
+ /** The record title for the approver (#32); null in admin views or when not readable. */
17
+ recordTitle: string | null;
16
18
  definition: string;
17
19
  label: string;
18
20
  version: number;
@@ -38,8 +40,18 @@ export type WorkflowRun = {
38
40
  myApproval: {
39
41
  assignmentId: number;
40
42
  title: string;
43
+ /** Set at a decision step: its outcomes in declaration order (#42). */
44
+ decision?: WorkflowDecisionForm;
41
45
  } | null;
42
46
  };
47
+ /** What an approver needs to take a decision at a decision step. */
48
+ export type WorkflowDecisionForm = {
49
+ outcomes: {
50
+ key: string;
51
+ label: string;
52
+ comment: 'optional' | 'required';
53
+ }[];
54
+ };
43
55
  export type WorkflowOptions = {
44
56
  post?: HttpPoster;
45
57
  /** Test hook simulating a crash after a step's effect and before its commit. */
@@ -87,7 +99,7 @@ export declare class WorkflowEngine {
87
99
  * Records an approver's decision. Only an open approval assigned to the actor
88
100
  * counts, and the run is locked so concurrent decisions cannot both apply.
89
101
  */
90
- decide(runId: string, actor: Actor, decision: 'approve' | 'reject', comment?: unknown): Promise<WorkflowRun>;
102
+ decide(runId: string, actor: Actor, decision: string, comment?: unknown): Promise<WorkflowRun>;
91
103
  /** Administrators put a failed run back to its failed step. */
92
104
  retry(runId: string, actorId: number): Promise<void>;
93
105
  /**
@@ -98,9 +110,17 @@ export declare class WorkflowEngine {
98
110
  runsFor(name: string, id: number, actor: Actor): Promise<WorkflowRun[]>;
99
111
  run(runId: string, actor: Actor): Promise<WorkflowRun>;
100
112
  /** Runs waiting for the actor's decision, newest first, on records they can still read. */
101
- inbox(actor: Actor): Promise<WorkflowRun[]>;
113
+ /**
114
+ * Runs waiting for this user's decision, newest first (at most 100). `runIds` narrows the
115
+ * inbox to those runs, for pages that list their own rows (My tasks).
116
+ */
117
+ inbox(actor: Actor, options?: {
118
+ runIds?: readonly string[];
119
+ }): Promise<WorkflowRun[]>;
102
120
  failed(limit?: number): Promise<WorkflowRun[]>;
103
121
  private present;
122
+ /** The outcomes an approver can choose at a decision step. */
123
+ private decisionForm;
104
124
  private fail;
105
125
  private cancelRuns;
106
126
  private context;
@@ -108,6 +128,8 @@ export declare class WorkflowEngine {
108
128
  private updateRecord;
109
129
  private cancelDocument;
110
130
  private recipients;
131
+ /** A role reference is its stable key; for compatibility a display name also matches. */
132
+ private roleId;
111
133
  private canView;
112
134
  private log;
113
135
  private now;
@@ -7,6 +7,12 @@ import { nextStep, } from './define_workflow.js';
7
7
  const MAX_STEPS_PER_TICK = 25;
8
8
  const BACKOFF_SECONDS = [30, 120, 600, 1800, 7200];
9
9
  class StepFailure extends Error {
10
+ permanent;
11
+ /** A permanent failure stops the run now instead of retrying a configuration error. */
12
+ constructor(message, permanent = false) {
13
+ super(message);
14
+ this.permanent = permanent;
15
+ }
10
16
  }
11
17
  /**
12
18
  * Durable workflow engine over workflow_runs. Every step runs inside the run's
@@ -174,7 +180,7 @@ export class WorkflowEngine {
174
180
  return;
175
181
  }
176
182
  catch (error) {
177
- await this.fail(trx, run, error, false);
183
+ await this.fail(trx, run, error, error instanceof StepFailure && error.permanent);
178
184
  return;
179
185
  }
180
186
  }
@@ -216,21 +222,27 @@ export class WorkflowEngine {
216
222
  id: run.record_id,
217
223
  workflow: definition.label,
218
224
  step: step.label ?? stepName,
219
- });
225
+ }, undefined, { resource: String(run.resource), recordId: Number(run.record_id) });
220
226
  return move({ type: 'DONE' }, { recipients: users.length });
221
227
  }
222
- case 'approval': {
228
+ case 'approval':
229
+ case 'decision': {
223
230
  const open = await trx('assignments')
224
231
  .where({ workflow_run_id: run.id, workflow_step: stepName, status: 'open' })
225
232
  .first('id');
226
233
  if (!open) {
227
- const users = await this.recipients(trx, run, context, step.assignees);
234
+ const assignees = step.assignees;
235
+ if (typeof assignees === 'object' && 'role' in assignees)
236
+ if ((await this.roleId(trx, assignees.role)) === null)
237
+ throw new StepFailure(`Approval step ${stepName} is addressed to role "${assignees.role}", which matches no role key or name. Give the role that key (or update the workflow), then retry the run.`, true);
238
+ const users = await this.recipients(trx, run, context, assignees);
228
239
  const eligible = [];
229
240
  for (const userId of users)
230
241
  if (await this.canView(run.resource, run.record_id, userId))
231
242
  eligible.push(userId);
243
+ // Waiting on nobody would hide the run for hours of retries; fail it where admins look.
232
244
  if (!eligible.length)
233
- throw new StepFailure(`No eligible approver for ${stepName}`);
245
+ throw new StepFailure(`No eligible approver for ${stepName}: ${users.length ? `${users.length} recipients cannot view the record` : 'the recipients resolve to no active user'}. Fix the recipients, then retry the run.`, true);
234
246
  const due = step.dueInDays
235
247
  ? new Date(this.now().getTime() + step.dueInDays * 86400000).toISOString().slice(0, 10)
236
248
  : null;
@@ -317,7 +329,7 @@ export class WorkflowEngine {
317
329
  * counts, and the run is locked so concurrent decisions cannot both apply.
318
330
  */
319
331
  async decide(runId, actor, decision, comment) {
320
- if (!['approve', 'reject'].includes(decision))
332
+ if (typeof decision !== 'string' || !decision)
321
333
  throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
322
334
  const note = typeof comment === 'string' ? comment.trim().slice(0, 1000) : '';
323
335
  await this.db.transaction(async (trx) => {
@@ -337,9 +349,25 @@ export class WorkflowEngine {
337
349
  if (run.status !== 'waiting' || !assignment)
338
350
  throw new KitError(409, 'E_WORKFLOW_STATE', 'لا توجد موافقة مطلوبة منك في هذه الخطوة');
339
351
  const definition = this.definition(run.definition, run.definition_version);
340
- const target = nextStep(definition, run.current_step, {
341
- type: decision === 'approve' ? 'APPROVE' : 'REJECT',
342
- });
352
+ const step = definition.steps[run.current_step];
353
+ let event;
354
+ let logged;
355
+ if (step?.type === 'decision') {
356
+ const outcome = Object.hasOwn(step.outcomes, decision) ? step.outcomes[decision] : undefined;
357
+ if (!outcome)
358
+ throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
359
+ if (outcome.comment === 'required' && !note)
360
+ throw new KitError(422, 'E_WORKFLOW_COMMENT', 'اكتب ملاحظة القرار');
361
+ event = { type: 'DECIDE', outcome: decision };
362
+ logged = 'decided';
363
+ }
364
+ else {
365
+ if (decision !== 'approve' && decision !== 'reject')
366
+ throw new KitError(422, 'E_WORKFLOW_DECISION', 'القرار غير صالح');
367
+ event = { type: decision === 'approve' ? 'APPROVE' : 'REJECT' };
368
+ logged = decision === 'approve' ? 'approved' : 'rejected';
369
+ }
370
+ const target = nextStep(definition, run.current_step, event);
343
371
  await trx('assignments').where('id', assignment.id).update({
344
372
  status: 'done',
345
373
  outcome: decision,
@@ -361,7 +389,8 @@ export class WorkflowEngine {
361
389
  last_error: null,
362
390
  updated_at: trx.fn.now(),
363
391
  });
364
- await this.log(trx, run.id, run.current_step, decision === 'approve' ? 'approved' : 'rejected', actor.id, {
392
+ await this.log(trx, run.id, run.current_step, logged, actor.id, {
393
+ ...(logged === 'decided' ? { outcome: decision } : {}),
365
394
  ...(note ? { comment: note } : {}),
366
395
  next: target,
367
396
  });
@@ -425,14 +454,20 @@ export class WorkflowEngine {
425
454
  return this.present(row, actor);
426
455
  }
427
456
  /** Runs waiting for the actor's decision, newest first, on records they can still read. */
428
- async inbox(actor) {
429
- const rows = await this.db('workflow_runs as r')
457
+ /**
458
+ * Runs waiting for this user's decision, newest first (at most 100). `runIds` narrows the
459
+ * inbox to those runs, for pages that list their own rows (My tasks).
460
+ */
461
+ async inbox(actor, options = {}) {
462
+ if (options.runIds && !options.runIds.length)
463
+ return [];
464
+ const query = this.db('workflow_runs as r')
430
465
  .join('assignments as a', 'a.workflow_run_id', 'r.id')
431
466
  .where({ 'a.assignee_id': actor.id, 'a.status': 'open', 'r.status': 'waiting' })
432
- .whereRaw('a.workflow_step = r.current_step')
433
- .orderBy('a.id', 'desc')
434
- .limit(100)
435
- .select('r.*');
467
+ .whereRaw('a.workflow_step = r.current_step');
468
+ if (options.runIds)
469
+ query.whereIn('r.id', [...new Set(options.runIds)].slice(0, 100));
470
+ const rows = await query.orderBy('a.id', 'desc').limit(100).select('r.*');
436
471
  const runs = [];
437
472
  for (const row of rows)
438
473
  if (await this.resources.permits(row.resource, Number(row.record_id), actor))
@@ -469,6 +504,9 @@ export class WorkflowEngine {
469
504
  .first('id', 'title')
470
505
  : undefined;
471
506
  const step = row.current_step ? definition?.steps[row.current_step] : undefined;
507
+ const titles = actor
508
+ ? await this.resources.titles(row.resource, [Number(row.record_id)], actor)
509
+ : undefined;
472
510
  let resourceLabel = String(row.resource);
473
511
  try {
474
512
  resourceLabel = this.resources.label(row.resource);
@@ -479,6 +517,7 @@ export class WorkflowEngine {
479
517
  resource: String(row.resource),
480
518
  resourceLabel,
481
519
  recordId: Number(row.record_id),
520
+ recordTitle: titles?.get(Number(row.record_id)) ?? null,
482
521
  definition: String(row.definition),
483
522
  label: definition?.label ?? String(row.definition),
484
523
  version: Number(row.definition_version),
@@ -498,7 +537,23 @@ export class WorkflowEngine {
498
537
  detail: (event.detail ?? {}),
499
538
  at: new Date(event.created_at).toISOString(),
500
539
  })),
501
- myApproval: mine ? { assignmentId: Number(mine.id), title: String(mine.title) } : null,
540
+ myApproval: mine
541
+ ? {
542
+ assignmentId: Number(mine.id),
543
+ title: String(mine.title),
544
+ ...(step?.type === 'decision' ? { decision: this.decisionForm(step) } : {}),
545
+ }
546
+ : null,
547
+ };
548
+ }
549
+ /** The outcomes an approver can choose at a decision step. */
550
+ decisionForm(step) {
551
+ return {
552
+ outcomes: Object.entries(step.outcomes).map(([key, outcome]) => ({
553
+ key,
554
+ label: outcome.label,
555
+ comment: outcome.comment ?? 'optional',
556
+ })),
502
557
  };
503
558
  }
504
559
  async fail(trx, run, error, permanent) {
@@ -532,7 +587,7 @@ export class WorkflowEngine {
532
587
  resource: this.resources.label(run.resource),
533
588
  id: run.record_id,
534
589
  error: message,
535
- });
590
+ }, undefined, { resource: String(run.resource), recordId: Number(run.record_id) });
536
591
  }
537
592
  async cancelRuns(trx, resource, id, actorId) {
538
593
  const runs = await trx('workflow_runs')
@@ -635,16 +690,26 @@ export class WorkflowEngine {
635
690
  else if ('users' in to)
636
691
  ids = to.users;
637
692
  else {
638
- const members = await trx('user_roles as ur')
639
- .join('roles as r', 'r.id', 'ur.role_id')
640
- .join('users as u', 'u.id', 'ur.user_id')
641
- .where('r.name', to.role)
642
- .whereNull('u.disabled_at')
643
- .distinct('ur.user_id');
693
+ const roleId = await this.roleId(trx, to.role);
694
+ const members = roleId === null
695
+ ? []
696
+ : await trx('user_roles as ur')
697
+ .join('users as u', 'u.id', 'ur.user_id')
698
+ .where('ur.role_id', roleId)
699
+ .whereNull('u.disabled_at')
700
+ .distinct('ur.user_id');
644
701
  ids = members.map((row) => Number(row.user_id));
645
702
  }
646
703
  return [...new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0))];
647
704
  }
705
+ /** A role reference is its stable key; for compatibility a display name also matches. */
706
+ async roleId(db, reference) {
707
+ const byKey = await db('roles').where('key', reference).first('id');
708
+ if (byKey)
709
+ return Number(byKey.id);
710
+ const byName = await db('roles').where('name', reference).first('id');
711
+ return byName ? Number(byName.id) : null;
712
+ }
648
713
  async canView(resource, id, userId) {
649
714
  const user = await this.db('users').where('id', userId).first('disabled_at');
650
715
  if (!user || user.disabled_at)
@@ -5,7 +5,7 @@ import db from '@adonisjs/lucid/services/db'
5
5
  import { kit } from '#services/kit'
6
6
  import { randomUUID } from 'node:crypto'
7
7
  import { columnName } from '@adula/kit'
8
- import type { Field, RecordData, Resource, SerializedRecord } from '@adula/kit'
8
+ import type { Action, Field, RecordData, Resource, SerializedRecord } from '@adula/kit'
9
9
 
10
10
  export type FixtureContext = { userId: number; orgUnitId: number; unique: string }
11
11
  export type ContractFixture = {
@@ -16,9 +16,12 @@ export type ContractFixture = {
16
16
  stored?: RecordData
17
17
  /** All live child rows after creation, ordered by ID, using public field names. */
18
18
  inline?: Record<string, RecordData[]>
19
- /** A valid update for this same record, including required validator fields. */
20
- update: RecordData
21
- updated: SerializedRecord
19
+ /**
20
+ * A valid update for this same record, including required validator fields. Required
21
+ * when the resource declares the `update` action.
22
+ */
23
+ update?: RecordData
24
+ updated?: SerializedRecord
22
25
  updatedStored?: RecordData
23
26
  updatedInline?: Record<string, RecordData[]>
24
27
  }
@@ -48,8 +51,13 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
48
51
  let resource: Resource
49
52
  const base = `/resources/${name}`
50
53
  const fresh = () => fixture({ userId: writer.id, orgUnitId, unique: randomUUID() })
51
- const input = (values: RecordData) => ({ ...values, ...(resource.scoped ? { orgUnitId } : {}) })
54
+ const input = (values: RecordData) => ({
55
+ ...values,
56
+ ...(resource.scoped && !resource.scope ? { orgUnitId } : {}),
57
+ })
52
58
  const version = (row: SerializedRecord) => (resource.version ? { version: row.version } : {})
59
+ // Actions the resource does not declare are refused even to a role that manages all.
60
+ const allows = (action: Action) => resource.actions.includes(action)
53
61
 
54
62
  group.setup(async () => {
55
63
  const knex = db.connection().getWriteClient()
@@ -124,10 +132,27 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
124
132
  const response = await client.get(base).header('Accept', 'application/json')
125
133
  response.assertStatus(401)
126
134
  })
135
+ test('undeclared actions are refused to a role that manages all resources', async ({
136
+ client,
137
+ }) => {
138
+ for (const [action, method, suffix] of [
139
+ ['create', 'post', ''],
140
+ ['update', 'patch', '/999999'],
141
+ ['delete', 'delete', '/999999'],
142
+ ] as const) {
143
+ if (allows(action)) continue
144
+ const response = await client[method](`${base}${suffix}`)
145
+ .loginAs(writer)
146
+ .withCsrfToken()
147
+ .header('Accept', 'application/json')
148
+ response.assertStatus(403)
149
+ }
150
+ })
127
151
  test('existing records enforce scope on reads and writes; central resources stay shared', async ({
128
152
  client,
129
153
  assert,
130
154
  }) => {
155
+ if (!allows('create')) return
131
156
  const values = await fresh()
132
157
  const created = await client
133
158
  .post(base)
@@ -159,21 +184,21 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
159
184
  .get(`${base}/${row.id}/edit`)
160
185
  .loginAs(outsider)
161
186
  .header('Accept', 'application/json')
162
- edit.assertStatus(404)
187
+ edit.assertStatus(allows('update') ? 404 : 403)
163
188
  const changed = await client
164
189
  .patch(`${base}/${row.id}`)
165
190
  .loginAs(outsider)
166
191
  .withCsrfToken()
167
192
  .header('Accept', 'application/json')
168
- .json({ ...values.update, ...version(row) })
169
- changed.assertStatus(404)
193
+ .json({ ...(values.update ?? values.input), ...version(row) })
194
+ changed.assertStatus(allows('update') ? 404 : 403)
170
195
  const deleted = await client
171
196
  .delete(`${base}/${row.id}`)
172
197
  .loginAs(outsider)
173
198
  .withCsrfToken()
174
199
  .header('Accept', 'application/json')
175
200
  .json(version(row))
176
- deleted.assertStatus(404)
201
+ deleted.assertStatus(allows('delete') ? 404 : 403)
177
202
  const after = await client
178
203
  .get(`${base}/${row.id}`)
179
204
  .loginAs(writer)
@@ -185,6 +210,7 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
185
210
  client,
186
211
  assert,
187
212
  }) => {
213
+ if (!allows('create')) return
188
214
  const values = await fresh()
189
215
  for (const key of resource.form)
190
216
  assert.property(values.input, key, `Missing ${name}.${key} fixture`)
@@ -268,64 +294,99 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
268
294
  if (field.permissionLevel || resource.hidden?.includes(key))
269
295
  assert.notProperty(shown.body().data, key)
270
296
  }
271
- for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {
272
- const invalid = await client
297
+ const stored = async () => db.connection().getWriteClient()(name).where('id', row.id).first()
298
+ const inserted = await stored()
299
+ assert.equal(inserted.created_by, writer.id)
300
+ if (!allows('update')) {
301
+ const refused = await client
273
302
  .patch(`${base}/${row.id}`)
274
303
  .loginAs(writer)
275
304
  .withCsrfToken()
276
305
  .header('Accept', 'application/json')
277
- .json({ ...values.update, ...version(row), [key]: writer.id })
278
- invalid.assertStatus(422)
279
- assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')
306
+ .json({ ...values.input, ...version(row) })
307
+ refused.assertStatus(403)
280
308
  }
281
- const updated = await client
282
- .patch(`${base}/${row.id}`)
283
- .loginAs(writer)
284
- .withCsrfToken()
285
- .header('Accept', 'application/json')
286
- .json({ ...values.update, ...version(row) })
287
- updated.assertStatus(200)
288
- for (const [key, value] of Object.entries(values.updated))
289
- assert.deepEqual(updated.body().data[key], value, key)
290
- await assertStored(values.update, values.updated, values.updatedStored, values.updatedInline)
291
- if (resource.version) {
292
- assert.equal(updated.body().data.version, Number(row.version) + 1)
293
- const stale = await client
294
- .patch(`${base}/${row.id}`)
309
+ const current = allows('update') ? await updateRecord() : row
310
+ if (!allows('delete')) {
311
+ const refused = await client
312
+ .delete(`${base}/${row.id}`)
295
313
  .loginAs(writer)
296
314
  .withCsrfToken()
297
315
  .header('Accept', 'application/json')
298
- .json({ ...values.update, ...version(row) })
299
- stale.assertStatus(409)
316
+ .json(version(current))
317
+ refused.assertStatus(403)
318
+ const kept = await client
319
+ .get(`${base}/${row.id}`)
320
+ .loginAs(writer)
321
+ .header('Accept', 'application/json')
322
+ kept.assertStatus(200)
323
+ assert.deepEqual(kept.body().data, current)
324
+ return
300
325
  }
301
- const persisted = await client
302
- .get(`${base}/${row.id}`)
303
- .loginAs(writer)
304
- .header('Accept', 'application/json')
305
- persisted.assertStatus(200)
306
- assert.deepEqual(persisted.body().data, updated.body().data)
307
326
  const removed = await client
308
327
  .delete(`${base}/${row.id}`)
309
328
  .loginAs(writer)
310
329
  .withCsrfToken()
311
330
  .header('Accept', 'application/json')
312
- .json(version(updated.body().data))
331
+ .json(version(current))
313
332
  removed.assertStatus(200)
314
- const stored = await db.connection().getWriteClient()(name).where('id', row.id).first()
315
- assert.exists(stored.deleted_at)
316
- assert.equal(stored.created_by, writer.id)
333
+ const deleted = await stored()
334
+ assert.exists(deleted.deleted_at)
317
335
  const missing = await client
318
336
  .get(`${base}/${row.id}`)
319
337
  .loginAs(writer)
320
338
  .header('Accept', 'application/json')
321
339
  missing.assertStatus(404)
340
+
341
+ async function updateRecord() {
342
+ assert.exists(values.update, `Missing ${name} update fixture`)
343
+ assert.exists(values.updated, `Missing ${name} updated fixture`)
344
+ const update = values.update!
345
+ for (const key of ['createdBy', 'updatedBy', 'deletedAt', 'orgPath', 'searchVector']) {
346
+ const invalid = await client
347
+ .patch(`${base}/${row.id}`)
348
+ .loginAs(writer)
349
+ .withCsrfToken()
350
+ .header('Accept', 'application/json')
351
+ .json({ ...update, ...version(row), [key]: writer.id })
352
+ invalid.assertStatus(422)
353
+ assert.equal(invalid.body().error.code, 'E_FIELD_NOT_WRITABLE')
354
+ }
355
+ const updated = await client
356
+ .patch(`${base}/${row.id}`)
357
+ .loginAs(writer)
358
+ .withCsrfToken()
359
+ .header('Accept', 'application/json')
360
+ .json({ ...update, ...version(row) })
361
+ updated.assertStatus(200)
362
+ for (const [key, value] of Object.entries(values.updated!))
363
+ assert.deepEqual(updated.body().data[key], value, key)
364
+ await assertStored(update, values.updated!, values.updatedStored, values.updatedInline)
365
+ if (resource.version) {
366
+ assert.equal(updated.body().data.version, Number(row.version) + 1)
367
+ const stale = await client
368
+ .patch(`${base}/${row.id}`)
369
+ .loginAs(writer)
370
+ .withCsrfToken()
371
+ .header('Accept', 'application/json')
372
+ .json({ ...update, ...version(row) })
373
+ stale.assertStatus(409)
374
+ }
375
+ const persisted = await client
376
+ .get(`${base}/${row.id}`)
377
+ .loginAs(writer)
378
+ .header('Accept', 'application/json')
379
+ persisted.assertStatus(200)
380
+ assert.deepEqual(persisted.body().data, updated.body().data)
381
+ return updated.body().data as SerializedRecord
382
+ }
322
383
  })
323
384
  test('unique constraints reject duplicates and allow reuse after soft deletion', async ({
324
385
  client,
325
386
  assert,
326
387
  }) => {
327
388
  const keys = Object.entries(resource.fields).filter(([, field]) => field.unique)
328
- if (!keys.length) return
389
+ if (!keys.length || !allows('create')) return
329
390
  const values = await fresh()
330
391
  const created = await client
331
392
  .post(base)
@@ -369,6 +430,7 @@ export function resourceContract(name: string, fixture: ResourceFixture) {
369
430
  rejected.assertStatus(409)
370
431
  assert.equal(rejected.body().error.code, 'E_DUPLICATE')
371
432
  }
433
+ if (!allows('delete')) return
372
434
  const removed = await client
373
435
  .delete(`${base}/${row.id}`)
374
436
  .loginAs(writer)