@adula/kit 0.2.0-alpha.3 → 1.0.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 (72) hide show
  1. package/README.md +12 -2
  2. package/build/agent/capabilities.md +49 -7
  3. package/build/agent/skills/idea-review/SKILL.md +26 -1
  4. package/build/agent/skills/module-review/SKILL.md +22 -0
  5. package/build/agent/skills/perf-review/SKILL.md +24 -0
  6. package/build/agent/skills/schema-review/SKILL.md +22 -0
  7. package/build/agent/skills/security-review/SKILL.md +24 -0
  8. package/build/agent/skills/ui-review/SKILL.md +22 -0
  9. package/build/commands/capabilities.d.ts +4 -0
  10. package/build/commands/capabilities.js +35 -4
  11. package/build/commands/gaps.js +5 -8
  12. package/build/commands/main.d.ts +4 -2
  13. package/build/commands/main.js +2 -0
  14. package/build/database/migrations/1770000000004_kit_collaboration.d.ts +5 -0
  15. package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
  16. package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
  17. package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
  18. package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
  19. package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
  20. package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
  21. package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
  22. package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
  23. package/build/database/migrations/1770000000008_kit_imports.js +10 -0
  24. package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
  25. package/build/database/migrations/1770000000010_kit_workflows.js +10 -0
  26. package/build/index.d.ts +22 -2
  27. package/build/index.js +13 -2
  28. package/build/src/admin/contracts.js +19 -10
  29. package/build/src/admin/controller.d.ts +1 -0
  30. package/build/src/admin/controller.js +6 -0
  31. package/build/src/admin/resource_service.d.ts +48 -3
  32. package/build/src/admin/resource_service.js +194 -18
  33. package/build/src/attachments/attachment_service.d.ts +18 -1
  34. package/build/src/attachments/attachment_service.js +62 -0
  35. package/build/src/auth/ability.d.ts +2 -0
  36. package/build/src/collaboration/assignments.d.ts +78 -0
  37. package/build/src/collaboration/assignments.js +219 -0
  38. package/build/src/collaboration/record_collaboration.d.ts +86 -0
  39. package/build/src/collaboration/record_collaboration.js +360 -0
  40. package/build/src/commands/agent_assets.js +5 -0
  41. package/build/src/commands/capabilities.d.ts +19 -0
  42. package/build/src/commands/capabilities.js +160 -0
  43. package/build/src/commands/gap_report.d.ts +8 -0
  44. package/build/src/commands/gap_report.js +12 -0
  45. package/build/src/core/message_templates.d.ts +80 -0
  46. package/build/src/core/message_templates.js +288 -0
  47. package/build/src/core/saved_views.js +31 -6
  48. package/build/src/core/user_invitations.js +2 -2
  49. package/build/src/database/schema.d.ts +18 -0
  50. package/build/src/database/schema.js +192 -0
  51. package/build/src/events/outbox.d.ts +1 -0
  52. package/build/src/events/outbox.js +1 -1
  53. package/build/src/events/record_mutation.d.ts +9 -0
  54. package/build/src/events/record_mutation.js +18 -3
  55. package/build/src/integrations/imports.d.ts +74 -0
  56. package/build/src/integrations/imports.js +331 -0
  57. package/build/src/integrations/openapi.d.ts +39 -0
  58. package/build/src/integrations/openapi.js +256 -0
  59. package/build/src/integrations/print.d.ts +37 -0
  60. package/build/src/integrations/print.js +123 -0
  61. package/build/src/integrations/webhooks.d.ts +98 -0
  62. package/build/src/integrations/webhooks.js +298 -0
  63. package/build/src/resource/define_resource.d.ts +1 -0
  64. package/build/src/resource/define_resource.js +9 -1
  65. package/build/src/resource/registry.d.ts +2 -0
  66. package/build/src/resource/registry.js +9 -0
  67. package/build/src/resource/types.d.ts +13 -1
  68. package/build/src/workflows/define_workflow.d.ts +95 -0
  69. package/build/src/workflows/define_workflow.js +83 -0
  70. package/build/src/workflows/engine.d.ts +119 -0
  71. package/build/src/workflows/engine.js +687 -0
  72. package/package.json +7 -13
@@ -0,0 +1,123 @@
1
+ const escape = (value) => value
2
+ .replaceAll('&', '&')
3
+ .replaceAll('<', '&lt;')
4
+ .replaceAll('>', '&gt;')
5
+ .replaceAll('"', '&quot;')
6
+ .replaceAll("'", '&#39;');
7
+ function text(field, value, input) {
8
+ if (value === null || value === undefined || value === '')
9
+ return '—';
10
+ switch (field.type) {
11
+ case 'boolean':
12
+ return value ? 'نعم' : 'لا';
13
+ case 'money': {
14
+ // Same presentation as the UI: Latin digits, grouped, two minor digits.
15
+ if (!/^-?\d+$/.test(String(value)))
16
+ return String(value);
17
+ const minor = BigInt(String(value));
18
+ const abs = minor < 0n ? -minor : minor;
19
+ return `${minor < 0n ? '-' : ''}${new Intl.NumberFormat('ar-u-nu-latn').format(abs / 100n)}.${String(abs % 100n).padStart(2, '0')}`;
20
+ }
21
+ case 'lookup':
22
+ return (input.lookups?.[field.key]?.find((option) => option.value === value)?.label ?? String(value));
23
+ case 'belongsTo': {
24
+ const related = input.related?.[field.key]?.find((row) => row.id === value);
25
+ const label = related ? Object.entries(related).find(([key]) => key !== 'id')?.[1] : null;
26
+ return label === null || label === undefined ? `#${String(value)}` : String(label);
27
+ }
28
+ case 'attachment':
29
+ return typeof value === 'object' && !Array.isArray(value) && value.name
30
+ ? String(value.name)
31
+ : 'مرفق';
32
+ case 'datetime':
33
+ return new Date(String(value)).toLocaleString('ar-u-nu-latn-ca-gregory', { timeZone: 'UTC' });
34
+ case 'json':
35
+ return JSON.stringify(value);
36
+ default:
37
+ return String(value);
38
+ }
39
+ }
40
+ /**
41
+ * The generic printable view of one record: RTL A4 HTML, every value escaped.
42
+ * Only fields already serialized for the caller are printed, so printing can
43
+ * never reveal more than the detail page.
44
+ */
45
+ export function renderPrintHtml(input) {
46
+ const printedAt = input.printedAt ?? new Date();
47
+ const fields = input.resource.fields.filter((field) => input.resource.show.includes(field.key) &&
48
+ field.type !== 'hasMany' &&
49
+ field.key in input.record);
50
+ const status = input.resource.submittable && typeof input.record.docStatus === 'number'
51
+ ? ['مسودة', 'معتمد', 'ملغي'][input.record.docStatus]
52
+ : null;
53
+ const rows = fields
54
+ .map((field) => `<tr><th scope="row">${escape(field.label.ar)}</th><td>${escape(text(field, input.record[field.key], input))}</td></tr>`)
55
+ .join('');
56
+ const children = Object.entries(input.children ?? {})
57
+ .map(([key, group]) => {
58
+ const label = input.resource.fields.find((field) => field.key === key)?.label.ar ?? key;
59
+ const columns = group.fields.filter((field) => field.type !== 'hasMany');
60
+ return `<section><h2>${escape(label)}</h2><table class="lines"><thead><tr>${columns
61
+ .map((column) => `<th scope="col">${escape(column.label.ar)}</th>`)
62
+ .join('')}</tr></thead><tbody>${group.rows
63
+ .map((row) => `<tr>${columns.map((column) => `<td>${escape(text(column, row[column.key], input))}</td>`).join('')}</tr>`)
64
+ .join('')}</tbody></table></section>`;
65
+ })
66
+ .join('');
67
+ const logo = input.identity.logoUrl
68
+ ? `<img class="logo" src="${escape(input.identity.logoUrl)}" alt="">`
69
+ : '';
70
+ return `<!doctype html>
71
+ <html lang="ar" dir="rtl">
72
+ <head>
73
+ <meta charset="utf-8">
74
+ <title>${escape(input.resource.label)} #${escape(String(input.record.id))}</title>
75
+ <style>
76
+ @page { size: A4; margin: 18mm 16mm; }
77
+ * { box-sizing: border-box; }
78
+ body { font-family: "Noto Sans Arabic", "Segoe UI", Tahoma, sans-serif; color: #1c1917; font-size: 12pt; margin: 0; }
79
+ header { display: flex; align-items: center; justify-content: space-between; border-bottom: 2px solid #1c1917; padding-bottom: 8mm; margin-bottom: 8mm; }
80
+ header h1 { font-size: 18pt; margin: 0; }
81
+ header p { margin: 2mm 0 0; color: #57534e; font-size: 10pt; }
82
+ .logo { max-height: 18mm; max-width: 50mm; }
83
+ table { width: 100%; border-collapse: collapse; }
84
+ th, td { text-align: start; padding: 2.5mm 3mm; border-bottom: 1px solid #e7e5e4; vertical-align: top; }
85
+ .fields th { width: 35%; color: #57534e; font-weight: 600; }
86
+ .lines thead th { background: #f5f5f4; font-size: 10pt; }
87
+ h2 { font-size: 13pt; margin: 8mm 0 3mm; }
88
+ .status { display: inline-block; border: 1px solid #1c1917; border-radius: 3mm; padding: 1mm 3mm; font-size: 10pt; }
89
+ footer { margin-top: 10mm; color: #78716c; font-size: 9pt; }
90
+ @media screen { body { max-width: 210mm; margin: 10mm auto; padding: 0 10mm; } }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <header>
95
+ <div>
96
+ <h1>${escape(input.resource.label)} #${escape(String(input.record.id))}</h1>
97
+ <p>${escape(input.identity.name)}</p>
98
+ </div>
99
+ <div>${status ? `<span class="status">${escape(status)}</span>` : ''}${logo}</div>
100
+ </header>
101
+ <table class="fields"><tbody>${rows}</tbody></table>
102
+ ${children}
103
+ <footer>طُبع بواسطة ${escape(input.printedBy)} في ${escape(printedAt.toISOString().slice(0, 16).replace('T', ' '))} UTC</footer>
104
+ </body>
105
+ </html>`;
106
+ }
107
+ /**
108
+ * Converts printable HTML to PDF with an optional Gotenberg service
109
+ * (POST /forms/chromium/convert/html). Without it, pages use the browser's print.
110
+ */
111
+ export async function htmlToPdf(html, options) {
112
+ const form = new FormData();
113
+ form.append('files', new Blob([html], { type: 'text/html' }), 'index.html');
114
+ form.append('printBackground', 'true');
115
+ form.append('preferCssPageSize', 'true');
116
+ const response = await (options.fetch ?? fetch)(`${options.gotenbergUrl.replace(/\/$/, '')}/forms/chromium/convert/html`, { method: 'POST', body: form, signal: AbortSignal.timeout(options.timeoutMs ?? 30000) });
117
+ if (!response.ok)
118
+ throw new Error(`PDF conversion failed with HTTP ${response.status}`);
119
+ const bytes = Buffer.from(await response.arrayBuffer());
120
+ if (!bytes.subarray(0, 5).equals(Buffer.from('%PDF-')))
121
+ throw new Error('PDF conversion returned a non-PDF response');
122
+ return bytes;
123
+ }
@@ -0,0 +1,98 @@
1
+ import type { Knex } from 'knex';
2
+ import type { ResourceRegistry } from '../resource/registry.js';
3
+ import type { Listener } from '../events/outbox.js';
4
+ /** Seals webhook secrets at rest; hosts pass their framework encryption service. */
5
+ export type SecretBox = {
6
+ seal(value: string): string;
7
+ open(value: string): string | null;
8
+ };
9
+ export type Webhook = {
10
+ id: number;
11
+ name: string;
12
+ url: string;
13
+ events: string[];
14
+ active: boolean;
15
+ createdAt: string;
16
+ lastDeliveryAt: string | null;
17
+ failing: number;
18
+ };
19
+ export type WebhookDelivery = {
20
+ id: string;
21
+ event: string;
22
+ status: 'pending' | 'delivered' | 'failed';
23
+ attempts: number;
24
+ lastStatus: number | null;
25
+ lastError: string | null;
26
+ createdAt: string;
27
+ deliveredAt: string | null;
28
+ nextAttemptAt: string | null;
29
+ };
30
+ export type WebhookOptions = {
31
+ /** Allow http:// and private or loopback addresses (development and tests only). */
32
+ allowPrivateTargets?: boolean;
33
+ /** Hostname resolution; replaceable in tests. */
34
+ resolve?: (host: string) => Promise<string[]>;
35
+ };
36
+ export type HttpPoster = (url: string, init: {
37
+ headers: Record<string, string>;
38
+ body: string;
39
+ signal: AbortSignal;
40
+ }) => Promise<{
41
+ status: number;
42
+ }>;
43
+ export declare const WEBHOOK_MAX_ATTEMPTS: number;
44
+ /** Signature receivers verify: hex HMAC-SHA256 over `${timestamp}.${body}`. */
45
+ export declare function signWebhook(secret: string, timestamp: string, body: string): string;
46
+ /**
47
+ * Outgoing webhooks: administrators subscribe HTTPS endpoints to domain events.
48
+ * Deliveries are queued exactly once per event inside the listener transaction,
49
+ * signed with a per-webhook secret and retried with backoff by the worker.
50
+ * Payloads carry the event envelope only, never record fields.
51
+ */
52
+ export declare class Webhooks {
53
+ private db;
54
+ private registry;
55
+ private secrets;
56
+ private options;
57
+ constructor(db: Knex, registry: ResourceRegistry, secrets: SecretBox, options?: WebhookOptions);
58
+ /** Events a webhook may subscribe to, derived from the registry. */
59
+ events(): {
60
+ key: string;
61
+ resource: string;
62
+ verb: string;
63
+ }[];
64
+ list(): Promise<Webhook[]>;
65
+ /** Creates a webhook and returns its signing secret once; it is never shown again. */
66
+ create(actorId: number, input: {
67
+ name: unknown;
68
+ url: unknown;
69
+ events: unknown;
70
+ }): Promise<{
71
+ webhook: Webhook;
72
+ secret: string;
73
+ }>;
74
+ update(id: number, input: {
75
+ name: unknown;
76
+ url: unknown;
77
+ events: unknown;
78
+ active?: unknown;
79
+ }): Promise<void>;
80
+ remove(id: number): Promise<void>;
81
+ deliveries(id: number, limit?: number): Promise<WebhookDelivery[]>;
82
+ /** Puts a failed delivery back in the queue for an immediate attempt. */
83
+ retry(deliveryId: string): Promise<void>;
84
+ /** Catch-all listener that queues one delivery per matching active webhook. */
85
+ listener(): Listener;
86
+ /**
87
+ * Sends due deliveries. Each row is claimed with SKIP LOCKED; a crash after the
88
+ * HTTP call may repeat a delivery, so receivers deduplicate on X-Adula-Delivery.
89
+ */
90
+ deliver(post: HttpPoster, limit?: number): Promise<{
91
+ delivered: number;
92
+ failed: number;
93
+ }>;
94
+ private validate;
95
+ /** Refuses non-HTTPS URLs and hosts resolving to private addresses (SSRF). */
96
+ private assertTarget;
97
+ private present;
98
+ }
@@ -0,0 +1,298 @@
1
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { isIP } from 'node:net';
4
+ import { KitError } from '../admin/errors.js';
5
+ import { notifyWithTemplate } from '../core/message_templates.js';
6
+ const EVENT_VERBS = ['created', 'updated', 'deleted', 'submitted', 'cancelled', 'amended'];
7
+ const BACKOFF_MINUTES = [1, 5, 30, 120, 720];
8
+ export const WEBHOOK_MAX_ATTEMPTS = BACKOFF_MINUTES.length + 1;
9
+ const NAME_LIMIT = 100;
10
+ /** Signature receivers verify: hex HMAC-SHA256 over `${timestamp}.${body}`. */
11
+ export function signWebhook(secret, timestamp, body) {
12
+ return createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
13
+ }
14
+ function privateAddress(address) {
15
+ if (isIP(address) === 6) {
16
+ const lower = address.toLowerCase();
17
+ if (lower.startsWith('::ffff:'))
18
+ return privateAddress(lower.slice(7));
19
+ return (lower === '::1' ||
20
+ lower === '::' ||
21
+ lower.startsWith('fc') ||
22
+ lower.startsWith('fd') ||
23
+ lower.startsWith('fe8') ||
24
+ lower.startsWith('fe9') ||
25
+ lower.startsWith('fea') ||
26
+ lower.startsWith('feb'));
27
+ }
28
+ const [a, b] = address.split('.').map(Number);
29
+ return (a === 10 ||
30
+ a === 127 ||
31
+ a === 0 ||
32
+ (a === 169 && b === 254) ||
33
+ (a === 172 && b >= 16 && b <= 31) ||
34
+ (a === 192 && b === 168) ||
35
+ (a === 100 && b >= 64 && b <= 127) ||
36
+ a >= 224);
37
+ }
38
+ /**
39
+ * Outgoing webhooks: administrators subscribe HTTPS endpoints to domain events.
40
+ * Deliveries are queued exactly once per event inside the listener transaction,
41
+ * signed with a per-webhook secret and retried with backoff by the worker.
42
+ * Payloads carry the event envelope only, never record fields.
43
+ */
44
+ export class Webhooks {
45
+ db;
46
+ registry;
47
+ secrets;
48
+ options;
49
+ constructor(db, registry, secrets, options = {}) {
50
+ this.db = db;
51
+ this.registry = registry;
52
+ this.secrets = secrets;
53
+ this.options = options;
54
+ }
55
+ /** Events a webhook may subscribe to, derived from the registry. */
56
+ events() {
57
+ return this.registry.all().flatMap((resource) => EVENT_VERBS.filter((verb) => !['submitted', 'cancelled', 'amended'].includes(verb) || Boolean(resource.submittable)).map((verb) => ({
58
+ key: `${this.registry.owner(resource.name)}.${resource.name}.${verb}`,
59
+ resource: resource.label.ar,
60
+ verb,
61
+ })));
62
+ }
63
+ async list() {
64
+ const rows = await this.db('webhooks').orderBy('id');
65
+ return rows.map((row) => this.present(row));
66
+ }
67
+ /** Creates a webhook and returns its signing secret once; it is never shown again. */
68
+ async create(actorId, input) {
69
+ const values = await this.validate(input);
70
+ const secret = randomBytes(32).toString('base64url');
71
+ const [row] = await this.db('webhooks')
72
+ .insert({ ...values, secret: this.secrets.seal(secret), created_by: actorId })
73
+ .returning('*');
74
+ return { webhook: this.present(row), secret };
75
+ }
76
+ async update(id, input) {
77
+ const values = await this.validate(input);
78
+ const updated = await this.db('webhooks')
79
+ .where('id', id)
80
+ .update({ ...values, active: input.active !== false, updated_at: this.db.fn.now() });
81
+ if (!updated)
82
+ throw new KitError(404, 'E_WEBHOOK_NOT_FOUND', 'الـWebhook غير موجود');
83
+ }
84
+ async remove(id) {
85
+ const removed = await this.db('webhooks').where('id', id).del();
86
+ if (!removed)
87
+ throw new KitError(404, 'E_WEBHOOK_NOT_FOUND', 'الـWebhook غير موجود');
88
+ }
89
+ async deliveries(id, limit = 50) {
90
+ if (!(await this.db('webhooks').where('id', id).first('id')))
91
+ throw new KitError(404, 'E_WEBHOOK_NOT_FOUND', 'الـWebhook غير موجود');
92
+ const rows = await this.db('webhook_deliveries')
93
+ .where('webhook_id', id)
94
+ .orderBy('created_at', 'desc')
95
+ .limit(Math.min(100, Math.max(1, limit)));
96
+ return rows.map((row) => ({
97
+ id: String(row.id),
98
+ event: String(row.event),
99
+ status: row.status,
100
+ attempts: Number(row.attempts),
101
+ lastStatus: row.last_status === null ? null : Number(row.last_status),
102
+ lastError: row.last_error === null ? null : String(row.last_error),
103
+ createdAt: new Date(row.created_at).toISOString(),
104
+ deliveredAt: row.delivered_at ? new Date(row.delivered_at).toISOString() : null,
105
+ nextAttemptAt: row.status === 'pending' ? new Date(row.next_attempt_at).toISOString() : null,
106
+ }));
107
+ }
108
+ /** Puts a failed delivery back in the queue for an immediate attempt. */
109
+ async retry(deliveryId) {
110
+ const updated = await this.db('webhook_deliveries')
111
+ .where({ id: deliveryId, status: 'failed' })
112
+ .update({ status: 'pending', attempts: 0, next_attempt_at: this.db.fn.now() });
113
+ if (!updated)
114
+ throw new KitError(404, 'E_DELIVERY_NOT_FOUND', 'لا توجد محاولة فاشلة بهذا المعرّف');
115
+ }
116
+ /** Catch-all listener that queues one delivery per matching active webhook. */
117
+ listener() {
118
+ return {
119
+ name: 'kit.webhooks',
120
+ event: '*',
121
+ handle: async (event, trx) => {
122
+ const hooks = await trx('webhooks')
123
+ .where('active', true)
124
+ .whereRaw('events @> ?::jsonb', [JSON.stringify([event.event])])
125
+ .select('id');
126
+ if (!hooks.length)
127
+ return;
128
+ const payload = {
129
+ id: event.id,
130
+ event: event.event,
131
+ resource: event.payload.resource ?? null,
132
+ recordId: event.payload.id ?? null,
133
+ actorId: event.payload.actorId ?? null,
134
+ occurredAt: new Date().toISOString(),
135
+ };
136
+ await trx('webhook_deliveries')
137
+ .insert(hooks.map((hook) => ({
138
+ id: randomUUID(),
139
+ webhook_id: hook.id,
140
+ event_id: event.id,
141
+ event: event.event,
142
+ payload: JSON.stringify(payload),
143
+ })))
144
+ .onConflict(['webhook_id', 'event_id'])
145
+ .ignore();
146
+ },
147
+ };
148
+ }
149
+ /**
150
+ * Sends due deliveries. Each row is claimed with SKIP LOCKED; a crash after the
151
+ * HTTP call may repeat a delivery, so receivers deduplicate on X-Adula-Delivery.
152
+ */
153
+ async deliver(post, limit = 20) {
154
+ const claimed = await this.db.transaction(async (trx) => {
155
+ const rows = await trx('webhook_deliveries as d')
156
+ .join('webhooks as w', 'w.id', 'd.webhook_id')
157
+ .where('d.status', 'pending')
158
+ .where('d.next_attempt_at', '<=', trx.fn.now())
159
+ .where('w.active', true)
160
+ .orderBy('d.next_attempt_at')
161
+ .limit(limit)
162
+ .forUpdate('d')
163
+ .skipLocked()
164
+ .select('d.*', 'w.url', 'w.secret', 'w.name', 'w.created_by');
165
+ // Lease the rows so a parallel worker skips them while HTTP calls run.
166
+ if (rows.length)
167
+ await trx('webhook_deliveries')
168
+ .whereIn('id', rows.map((row) => row.id))
169
+ .update({ next_attempt_at: trx.raw("now() + interval '2 minutes'") });
170
+ return rows;
171
+ });
172
+ let delivered = 0;
173
+ let failed = 0;
174
+ for (const row of claimed) {
175
+ const body = JSON.stringify(row.payload);
176
+ const timestamp = String(Math.floor(Date.now() / 1000));
177
+ const attempts = Number(row.attempts) + 1;
178
+ let status = null;
179
+ let error = null;
180
+ try {
181
+ const secret = this.secrets.open(String(row.secret));
182
+ if (!secret)
183
+ throw new Error('Webhook secret cannot be opened with the current key');
184
+ await this.assertTarget(String(row.url));
185
+ const response = await post(String(row.url), {
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'User-Agent': 'adula-kit-webhooks',
189
+ 'X-Adula-Event': String(row.event),
190
+ 'X-Adula-Delivery': String(row.id),
191
+ 'X-Adula-Timestamp': timestamp,
192
+ 'X-Adula-Signature': `sha256=${signWebhook(secret, timestamp, body)}`,
193
+ },
194
+ body,
195
+ signal: AbortSignal.timeout(10000),
196
+ });
197
+ status = response.status;
198
+ if (status < 200 || status >= 300)
199
+ error = `HTTP ${status}`;
200
+ }
201
+ catch (caught) {
202
+ error = String(caught?.message ?? caught).slice(0, 500);
203
+ }
204
+ if (!error) {
205
+ await this.db('webhook_deliveries').where('id', row.id).update({
206
+ status: 'delivered',
207
+ attempts,
208
+ last_status: status,
209
+ last_error: null,
210
+ delivered_at: this.db.fn.now(),
211
+ });
212
+ await this.db('webhooks')
213
+ .where('id', row.webhook_id)
214
+ .update({ failing: 0, last_delivery_at: this.db.fn.now() });
215
+ delivered++;
216
+ continue;
217
+ }
218
+ failed++;
219
+ const exhausted = attempts >= WEBHOOK_MAX_ATTEMPTS;
220
+ await this.db.transaction(async (trx) => {
221
+ await trx('webhook_deliveries')
222
+ .where('id', row.id)
223
+ .update({
224
+ status: exhausted ? 'failed' : 'pending',
225
+ attempts,
226
+ last_status: status,
227
+ last_error: error,
228
+ next_attempt_at: exhausted
229
+ ? trx.fn.now()
230
+ : trx.raw(`now() + interval '${BACKOFF_MINUTES[attempts - 1]} minutes'`),
231
+ });
232
+ if (exhausted) {
233
+ await trx('webhooks').where('id', row.webhook_id).increment('failing', 1);
234
+ if (row.created_by)
235
+ await notifyWithTemplate(trx, Number(row.created_by), 'webhook.failed', {
236
+ name: row.name,
237
+ event: row.event,
238
+ error,
239
+ });
240
+ }
241
+ });
242
+ }
243
+ return { delivered, failed };
244
+ }
245
+ async validate(input) {
246
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
247
+ if (!name || name.length > NAME_LIMIT)
248
+ throw new KitError(422, 'E_WEBHOOK_NAME', 'الاسم مطلوب ولا يتجاوز 100 حرف');
249
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
250
+ await this.assertTarget(url);
251
+ const known = new Set(this.events().map((event) => event.key));
252
+ if (!Array.isArray(input.events) ||
253
+ !input.events.length ||
254
+ input.events.length > 100 ||
255
+ !input.events.every((event) => typeof event === 'string' && known.has(event)))
256
+ throw new KitError(422, 'E_WEBHOOK_EVENTS', 'اختر حدثاً واحداً على الأقل من الأحداث المعروفة');
257
+ return { name, url, events: JSON.stringify([...new Set(input.events)]) };
258
+ }
259
+ /** Refuses non-HTTPS URLs and hosts resolving to private addresses (SSRF). */
260
+ async assertTarget(value) {
261
+ let url;
262
+ try {
263
+ url = new URL(value);
264
+ }
265
+ catch {
266
+ throw new KitError(422, 'E_WEBHOOK_URL', 'عنوان URL غير صالح');
267
+ }
268
+ const allowPrivate = this.options.allowPrivateTargets === true;
269
+ if (url.protocol !== 'https:' && !(allowPrivate && url.protocol === 'http:'))
270
+ throw new KitError(422, 'E_WEBHOOK_URL', 'يجب أن يبدأ العنوان بـ https://');
271
+ if (url.username || url.password || value.length > 2000)
272
+ throw new KitError(422, 'E_WEBHOOK_URL', 'عنوان URL غير مسموح');
273
+ if (allowPrivate)
274
+ return;
275
+ const host = url.hostname.replace(/^\[|\]$/g, '');
276
+ const addresses = isIP(host)
277
+ ? [host]
278
+ : await (this.options.resolve ??
279
+ (async (name) => {
280
+ const entries = await lookup(name, { all: true });
281
+ return entries.map((entry) => entry.address);
282
+ }))(host).catch(() => []);
283
+ if (!addresses.length || addresses.some(privateAddress))
284
+ throw new KitError(422, 'E_WEBHOOK_URL', 'لا يُسمح بعناوين الشبكة الداخلية');
285
+ }
286
+ present(row) {
287
+ return {
288
+ id: Number(row.id),
289
+ name: String(row.name),
290
+ url: String(row.url),
291
+ events: Array.isArray(row.events) ? row.events.map(String) : [],
292
+ active: Boolean(row.active),
293
+ createdAt: new Date(row.created_at).toISOString(),
294
+ lastDeliveryAt: row.last_delivery_at ? new Date(row.last_delivery_at).toISOString() : null,
295
+ failing: Number(row.failing ?? 0),
296
+ };
297
+ }
298
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Field, Resource, ResourceInput } from './types.js';
2
2
  export declare function identifier(value: string): string;
3
+ /** camelCase field key → snake_case column; validated once and memoized (hot in row mapping). */
3
4
  export declare function columnName(name: string): string;
4
5
  export declare function defineResource<const F extends Record<string, Field>>(input: ResourceInput<F>): ResourceInput<F> & Resource;
@@ -3,8 +3,16 @@ export function identifier(value) {
3
3
  throw new Error(`Unsafe SQL identifier: ${value}`);
4
4
  return value;
5
5
  }
6
+ const columns = new Map();
7
+ /** camelCase field key → snake_case column; validated once and memoized (hot in row mapping). */
6
8
  export function columnName(name) {
7
- return identifier(name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`));
9
+ const cached = columns.get(name);
10
+ if (cached !== undefined)
11
+ return cached;
12
+ const column = identifier(name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`));
13
+ if (columns.size < 10000)
14
+ columns.set(name, column);
15
+ return column;
8
16
  }
9
17
  export function defineResource(input) {
10
18
  identifier(input.name);
@@ -6,4 +6,6 @@ export declare class ResourceRegistry {
6
6
  all(): Resource[];
7
7
  owner(name: string): string;
8
8
  modules(): Module[];
9
+ /** Workflow definitions declared by registered modules, validated against their resources. */
10
+ workflows(): import("../workflows/define_workflow.ts").WorkflowDefinition[];
9
11
  }
@@ -56,4 +56,13 @@ export class ResourceRegistry {
56
56
  modules() {
57
57
  return [...this.#modules.values()];
58
58
  }
59
+ /** Workflow definitions declared by registered modules, validated against their resources. */
60
+ workflows() {
61
+ return this.modules().flatMap((module) => {
62
+ for (const workflow of module.workflows ?? [])
63
+ if (this.owner(workflow.resource) !== module.name)
64
+ throw new Error(`Workflow ${workflow.name} must belong to the module of ${workflow.resource}`);
65
+ return [...(module.workflows ?? [])];
66
+ });
67
+ }
59
68
  }
@@ -1,5 +1,6 @@
1
1
  import type { Knex } from 'knex';
2
2
  import type { LucidModel } from '@adonisjs/lucid/types/model';
3
+ import type { WorkflowDefinition } from '../workflows/define_workflow.js';
3
4
  export type Label = {
4
5
  ar: string;
5
6
  en: string;
@@ -22,7 +23,16 @@ export type Field = {
22
23
  permissionLevel?: number;
23
24
  sequence?: string;
24
25
  } & ({
25
- type: 'string' | 'text' | 'integer' | 'money' | 'boolean' | 'date' | 'datetime' | 'json' | 'attachment';
26
+ type: 'string' | 'text' | 'integer' | 'money' | 'boolean' | 'date' | 'datetime' | 'json';
27
+ } | {
28
+ type: 'attachment';
29
+ /**
30
+ * Lower-case file extensions accepted for upload, or 'any'. Defaults to
31
+ * DEFAULT_ATTACHMENT_EXTENSIONS (documents, images and archives).
32
+ */
33
+ accept?: string[] | 'any';
34
+ /** Upload size limit such as '5mb'. Defaults to '20mb'. */
35
+ maxSize?: string;
26
36
  } | {
27
37
  type: 'belongsTo';
28
38
  resource: string;
@@ -69,6 +79,8 @@ export type Module = {
69
79
  label: Label;
70
80
  dependsOn: readonly string[];
71
81
  resources: readonly Resource[];
82
+ /** Versioned workflows of this module's submittable resources (phase 4). */
83
+ workflows?: readonly WorkflowDefinition[];
72
84
  };
73
85
  export type ResourceInput<F extends Record<string, Field>> = Omit<Resource, 'fields' | 'list' | 'form' | 'show' | 'hidden' | 'serialize'> & {
74
86
  fields: F;