@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.
- package/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +10 -5
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- package/build/commands/doctor.js +37 -1
- package/build/commands/gaps.js +16 -6
- package/build/commands/install.js +19 -5
- package/build/commands/main.js +2 -0
- package/build/commands/module_add.js +2 -2
- package/build/commands/resource.js +1 -1
- package/build/commands/resource_snapshot.d.ts +15 -0
- package/build/commands/resource_snapshot.js +61 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000011_kit_managed_assignments.js +10 -0
- package/build/database/migrations/1770000000012_kit_role_keys.d.ts +5 -0
- package/build/database/migrations/1770000000012_kit_role_keys.js +10 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.d.ts +5 -0
- package/build/database/migrations/1770000000013_kit_notification_targets.js +10 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.d.ts +5 -0
- package/build/database/migrations/1770000000014_kit_upload_grants.js +10 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000015_kit_inbound_webhooks.js +10 -0
- package/build/index.d.ts +14 -5
- package/build/index.js +6 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +15 -2
- package/build/src/admin/controller.d.ts +1 -0
- package/build/src/admin/controller.js +46 -1
- package/build/src/admin/presentation.d.ts +6 -0
- package/build/src/admin/record_title.d.ts +14 -0
- package/build/src/admin/record_title.js +50 -0
- package/build/src/admin/resource_service.d.ts +163 -2
- package/build/src/admin/resource_service.js +552 -43
- package/build/src/attachments/attachment_service.d.ts +12 -0
- package/build/src/attachments/attachment_service.js +28 -2
- package/build/src/attachments/upload_grants.d.ts +44 -0
- package/build/src/attachments/upload_grants.js +105 -0
- package/build/src/auth/ability.d.ts +1 -1
- package/build/src/auth/ability.js +4 -1
- package/build/src/auth/actor_store.js +6 -1
- package/build/src/auth/conditions.d.ts +15 -0
- package/build/src/auth/conditions.js +36 -0
- package/build/src/auth/sql.js +6 -2
- package/build/src/collaboration/assignments.d.ts +54 -3
- package/build/src/collaboration/assignments.js +125 -11
- package/build/src/collaboration/record_collaboration.js +5 -17
- package/build/src/commands/capabilities.js +22 -3
- package/build/src/commands/doctor.d.ts +31 -0
- package/build/src/commands/doctor.js +114 -0
- package/build/src/commands/gap_report.d.ts +50 -2
- package/build/src/commands/gap_report.js +102 -4
- package/build/src/commands/generator.js +3 -3
- package/build/src/commands/snapshot.d.ts +28 -0
- package/build/src/commands/snapshot.js +48 -0
- package/build/src/commands/source_markers.d.ts +10 -1
- package/build/src/commands/source_markers.js +36 -4
- package/build/src/core/administration_guard.js +3 -1
- package/build/src/core/message_templates.d.ts +4 -1
- package/build/src/core/message_templates.js +7 -2
- package/build/src/core/module_seed.d.ts +15 -0
- package/build/src/core/module_seed.js +31 -0
- package/build/src/core/notifications.d.ts +18 -1
- package/build/src/core/notifications.js +27 -1
- package/build/src/core/roles.d.ts +27 -1
- package/build/src/core/roles.js +133 -5
- package/build/src/database/schema.d.ts +27 -0
- package/build/src/database/schema.js +98 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/record_mutation.d.ts +4 -0
- package/build/src/events/record_mutation.js +2 -0
- package/build/src/integrations/imports.js +4 -2
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.js +69 -2
- package/build/src/integrations/print.js +1 -0
- package/build/src/resource/define_resource.js +12 -0
- package/build/src/resource/registry.d.ts +1 -0
- package/build/src/resource/registry.js +33 -0
- package/build/src/resource/types.d.ts +64 -1
- package/build/src/resource/values.js +2 -1
- package/build/src/services/settings.d.ts +13 -1
- package/build/src/services/settings.js +9 -2
- package/build/src/workflows/define_workflow.d.ts +30 -1
- package/build/src/workflows/define_workflow.js +44 -4
- package/build/src/workflows/engine.d.ts +24 -2
- package/build/src/workflows/engine.js +89 -24
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { KitError } from '../admin/errors.js';
|
|
3
|
+
/** Inbound payloads above this size are refused before verification. */
|
|
4
|
+
export const INBOUND_BODY_LIMIT = 1024 * 1024;
|
|
5
|
+
/** Stored deliveries older than this are removed by pruneDeliveries(). */
|
|
6
|
+
export const INBOUND_RETENTION_DAYS = 90;
|
|
7
|
+
const HEADER = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
8
|
+
const KEY = /^[a-z][a-z0-9_]{0,59}$/;
|
|
9
|
+
/** The domain event an inbound delivery raises: inbound.<source>.<event>. */
|
|
10
|
+
export function inboundEventName(source, event) {
|
|
11
|
+
const safe = event
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9_]+/g, '_')
|
|
14
|
+
.replace(/^_+|_+$/g, '')
|
|
15
|
+
.slice(0, 60) || 'received';
|
|
16
|
+
return `inbound.${source}.${safe}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Signed inbound webhooks (#30). Administrators create a source per sender (for example
|
|
20
|
+
* a Git host) and give it the generated secret. POST /webhooks/in/<key> verifies an
|
|
21
|
+
* HMAC over the raw body in constant time (X-Hub-Signature-256 by default), stores each
|
|
22
|
+
* delivery once by the sender's delivery id and raises inbound.<key>.<event> through the
|
|
23
|
+
* outbox, so module listeners run idempotently with retries and a delivery history.
|
|
24
|
+
*/
|
|
25
|
+
export class InboundWebhooks {
|
|
26
|
+
db;
|
|
27
|
+
secrets;
|
|
28
|
+
constructor(db, secrets) {
|
|
29
|
+
this.db = db;
|
|
30
|
+
this.secrets = secrets;
|
|
31
|
+
}
|
|
32
|
+
async list() {
|
|
33
|
+
const rows = await this.db('inbound_sources').orderBy('id');
|
|
34
|
+
return rows.map((row) => this.present(row));
|
|
35
|
+
}
|
|
36
|
+
/** Creates a source and returns its secret once; it is never shown again. */
|
|
37
|
+
async create(actorId, input) {
|
|
38
|
+
const values = this.validate(input, true);
|
|
39
|
+
if (await this.db('inbound_sources').where('key', values.key).first('id'))
|
|
40
|
+
throw new KitError(409, 'E_INBOUND_KEY', 'يوجد مصدر بهذا المفتاح');
|
|
41
|
+
const secret = randomBytes(32).toString('base64url');
|
|
42
|
+
const [row] = await this.db('inbound_sources')
|
|
43
|
+
.insert({ ...values, secret: this.secrets.seal(secret), created_by: actorId })
|
|
44
|
+
.returning('*');
|
|
45
|
+
return { source: this.present(row), secret };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Updates the fields given (name, algorithm, headers, body deduplication, state); fields
|
|
49
|
+
* left out keep their stored values. The key and secret stay.
|
|
50
|
+
*/
|
|
51
|
+
async update(id, input) {
|
|
52
|
+
const row = await this.db('inbound_sources').where('id', id).first();
|
|
53
|
+
if (!row)
|
|
54
|
+
throw new KitError(404, 'E_INBOUND_NOT_FOUND', 'المصدر غير موجود');
|
|
55
|
+
const given = (value) => value !== undefined && value !== null;
|
|
56
|
+
const algorithm = given(input.algorithm) ? input.algorithm : row.algorithm;
|
|
57
|
+
// A new algorithm without a new prefix keeps a default prefix in step with it.
|
|
58
|
+
const prefix = given(input.signaturePrefix)
|
|
59
|
+
? input.signaturePrefix
|
|
60
|
+
: given(input.algorithm) && row.signature_prefix === `${row.algorithm}=`
|
|
61
|
+
? `${String(algorithm)}=`
|
|
62
|
+
: row.signature_prefix;
|
|
63
|
+
const values = this.validate({
|
|
64
|
+
name: given(input.name) ? input.name : row.name,
|
|
65
|
+
algorithm,
|
|
66
|
+
signaturePrefix: prefix,
|
|
67
|
+
signatureHeader: given(input.signatureHeader)
|
|
68
|
+
? input.signatureHeader
|
|
69
|
+
: row.signature_header,
|
|
70
|
+
eventHeader: given(input.eventHeader) ? input.eventHeader : row.event_header,
|
|
71
|
+
deliveryHeader: given(input.deliveryHeader) ? input.deliveryHeader : row.delivery_header,
|
|
72
|
+
dedupeBody: given(input.dedupeBody) ? input.dedupeBody : row.dedupe_body,
|
|
73
|
+
}, false);
|
|
74
|
+
delete values.key;
|
|
75
|
+
if (given(input.active) && typeof input.active !== 'boolean')
|
|
76
|
+
throw new KitError(422, 'E_INBOUND_ACTIVE', 'حالة الاستقبال يجب أن تكون صحيحاً أو خطأ');
|
|
77
|
+
await this.db('inbound_sources')
|
|
78
|
+
.where('id', id)
|
|
79
|
+
.update({
|
|
80
|
+
...values,
|
|
81
|
+
active: typeof input.active === 'boolean' ? input.active : row.active,
|
|
82
|
+
updated_at: this.db.fn.now(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Issues a new secret; the old one stops verifying at once. */
|
|
86
|
+
async rotate(id) {
|
|
87
|
+
const secret = randomBytes(32).toString('base64url');
|
|
88
|
+
const updated = await this.db('inbound_sources')
|
|
89
|
+
.where('id', id)
|
|
90
|
+
.update({ secret: this.secrets.seal(secret), updated_at: this.db.fn.now() });
|
|
91
|
+
if (!updated)
|
|
92
|
+
throw new KitError(404, 'E_INBOUND_NOT_FOUND', 'المصدر غير موجود');
|
|
93
|
+
return secret;
|
|
94
|
+
}
|
|
95
|
+
async remove(id) {
|
|
96
|
+
const removed = await this.db('inbound_sources').where('id', id).del();
|
|
97
|
+
if (!removed)
|
|
98
|
+
throw new KitError(404, 'E_INBOUND_NOT_FOUND', 'المصدر غير موجود');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Verifies and records one delivery. Unknown or inactive sources and bad signatures
|
|
102
|
+
* are refused without storing anything; a repeated delivery id is acknowledged once.
|
|
103
|
+
*/
|
|
104
|
+
async receive(key, request) {
|
|
105
|
+
const body = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body ?? '');
|
|
106
|
+
if (body.length > INBOUND_BODY_LIMIT)
|
|
107
|
+
throw new KitError(413, 'E_INBOUND_TOO_LARGE', 'Payload too large');
|
|
108
|
+
const source = typeof key === 'string' && KEY.test(key)
|
|
109
|
+
? await this.db('inbound_sources').where({ key, active: true }).first()
|
|
110
|
+
: undefined;
|
|
111
|
+
if (!source)
|
|
112
|
+
throw new KitError(404, 'E_INBOUND_NOT_FOUND', 'Unknown webhook source');
|
|
113
|
+
const header = (name) => {
|
|
114
|
+
const value = request.headers[name.toLowerCase()];
|
|
115
|
+
return Array.isArray(value) ? value[0] : value;
|
|
116
|
+
};
|
|
117
|
+
// GitHub offers form-encoded deliveries by default; only the JSON body is supported.
|
|
118
|
+
const contentType = (header('content-type') ?? '').toLowerCase();
|
|
119
|
+
if (contentType && !contentType.startsWith('application/json'))
|
|
120
|
+
throw new KitError(415, 'E_INBOUND_CONTENT_TYPE', 'Set the webhook content type to application/json');
|
|
121
|
+
const secret = this.secrets.open(String(source.secret));
|
|
122
|
+
const signature = header(source.signature_header) ?? '';
|
|
123
|
+
const expected = `${source.signature_prefix}${createHmac(source.algorithm, secret ?? randomBytes(32))
|
|
124
|
+
.update(body)
|
|
125
|
+
.digest('hex')}`;
|
|
126
|
+
const given = Buffer.from(signature);
|
|
127
|
+
const wanted = Buffer.from(expected);
|
|
128
|
+
if (!secret || given.length !== wanted.length || !timingSafeEqual(given, wanted))
|
|
129
|
+
throw new KitError(401, 'E_INBOUND_SIGNATURE', 'Invalid signature');
|
|
130
|
+
let payload;
|
|
131
|
+
try {
|
|
132
|
+
payload = JSON.parse(body.toString('utf8') || 'null');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new KitError(400, 'E_INBOUND_BODY', 'The body must be JSON');
|
|
136
|
+
}
|
|
137
|
+
const eventName = (header(source.event_header) ?? 'received').slice(0, 100);
|
|
138
|
+
// Senders without a delivery id are deduplicated by the body itself.
|
|
139
|
+
const deliveryId = (header(source.delivery_header) ?? `sha256:${createHash('sha256').update(body).digest('hex')}`).slice(0, 200);
|
|
140
|
+
const event = inboundEventName(String(source.key), eventName);
|
|
141
|
+
return this.db.transaction(async (trx) => {
|
|
142
|
+
const eventId = randomUUID();
|
|
143
|
+
const [stored] = await trx('inbound_deliveries')
|
|
144
|
+
.insert({
|
|
145
|
+
id: randomUUID(),
|
|
146
|
+
source_id: source.id,
|
|
147
|
+
delivery_id: deliveryId,
|
|
148
|
+
// Senders sign the body only: a captured body replayed with another delivery id or
|
|
149
|
+
// event header is refused while the source deduplicates bodies (the default).
|
|
150
|
+
body_key: source.dedupe_body ? createHash('sha256').update(body).digest('hex') : null,
|
|
151
|
+
event: eventName,
|
|
152
|
+
payload: JSON.stringify(payload),
|
|
153
|
+
event_id: eventId,
|
|
154
|
+
})
|
|
155
|
+
// Either the delivery id or the body key already seen for this source.
|
|
156
|
+
.onConflict()
|
|
157
|
+
.ignore()
|
|
158
|
+
.returning('id');
|
|
159
|
+
if (!stored)
|
|
160
|
+
return { delivery: deliveryId, event, duplicate: true };
|
|
161
|
+
await trx('inbound_sources').where('id', source.id).update({ last_received_at: trx.fn.now() });
|
|
162
|
+
await trx('outbox').insert({
|
|
163
|
+
id: eventId,
|
|
164
|
+
event,
|
|
165
|
+
payload: JSON.stringify(this.envelope(source.key, deliveryId, eventName, payload)),
|
|
166
|
+
});
|
|
167
|
+
return { delivery: deliveryId, event, duplicate: false };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async deliveries(sourceId, limit = 50) {
|
|
171
|
+
const source = await this.db('inbound_sources').where('id', sourceId).first('key');
|
|
172
|
+
if (!source)
|
|
173
|
+
throw new KitError(404, 'E_INBOUND_NOT_FOUND', 'المصدر غير موجود');
|
|
174
|
+
const rows = await this.db('inbound_deliveries')
|
|
175
|
+
.where('source_id', sourceId)
|
|
176
|
+
.orderBy('received_at', 'desc')
|
|
177
|
+
.limit(Math.min(100, Math.max(1, limit)));
|
|
178
|
+
return rows.map((row) => ({
|
|
179
|
+
id: String(row.id),
|
|
180
|
+
deliveryId: String(row.delivery_id),
|
|
181
|
+
event: String(row.event),
|
|
182
|
+
eventName: inboundEventName(String(source.key), String(row.event)),
|
|
183
|
+
dispatches: Number(row.dispatches),
|
|
184
|
+
receivedAt: new Date(row.received_at).toISOString(),
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Raises a stored delivery again as a new domain event, for listeners that failed or
|
|
189
|
+
* were added later. Listeners must be idempotent on the delivery id.
|
|
190
|
+
*/
|
|
191
|
+
async redispatch(deliveryId) {
|
|
192
|
+
if (typeof deliveryId !== 'string' ||
|
|
193
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(deliveryId))
|
|
194
|
+
throw new KitError(404, 'E_INBOUND_DELIVERY_NOT_FOUND', 'الاستلام غير موجود');
|
|
195
|
+
return this.db.transaction(async (trx) => {
|
|
196
|
+
const row = await trx('inbound_deliveries as d')
|
|
197
|
+
.join('inbound_sources as s', 's.id', 'd.source_id')
|
|
198
|
+
.where('d.id', deliveryId)
|
|
199
|
+
.forUpdate('d')
|
|
200
|
+
.first('d.*', 's.key');
|
|
201
|
+
if (!row)
|
|
202
|
+
throw new KitError(404, 'E_INBOUND_DELIVERY_NOT_FOUND', 'الاستلام غير موجود');
|
|
203
|
+
const eventId = randomUUID();
|
|
204
|
+
await trx('outbox').insert({
|
|
205
|
+
id: eventId,
|
|
206
|
+
event: inboundEventName(String(row.key), String(row.event)),
|
|
207
|
+
payload: JSON.stringify(this.envelope(String(row.key), String(row.delivery_id), String(row.event), row.payload)),
|
|
208
|
+
});
|
|
209
|
+
await trx('inbound_deliveries')
|
|
210
|
+
.where('id', row.id)
|
|
211
|
+
.update({ event_id: eventId, dispatches: Number(row.dispatches) + 1 });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/** Removes stored deliveries older than the retention period (90 days by default). */
|
|
215
|
+
async pruneDeliveries(days = INBOUND_RETENTION_DAYS) {
|
|
216
|
+
if (!Number.isSafeInteger(days) || days < 1)
|
|
217
|
+
throw new KitError(422, 'E_INBOUND_RETENTION', 'Retention must be at least one day');
|
|
218
|
+
return this.db('inbound_deliveries')
|
|
219
|
+
.where('received_at', '<', this.db.raw(`now() - (? * interval '1 day')`, [days]))
|
|
220
|
+
.delete();
|
|
221
|
+
}
|
|
222
|
+
envelope(source, delivery, event, body) {
|
|
223
|
+
return { source, delivery, event, body };
|
|
224
|
+
}
|
|
225
|
+
validate(input, creating) {
|
|
226
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
227
|
+
const key = text(input.key);
|
|
228
|
+
if (creating && !KEY.test(key))
|
|
229
|
+
throw new KitError(422, 'E_INBOUND_KEY', 'المفتاح حروف إنجليزية صغيرة وأرقام وشرطة سفلية ويبدأ بحرف (مثل github)');
|
|
230
|
+
const name = text(input.name);
|
|
231
|
+
if (!name || name.length > 100)
|
|
232
|
+
throw new KitError(422, 'E_INBOUND_NAME', 'الاسم مطلوب ولا يتجاوز 100 حرف');
|
|
233
|
+
const algorithm = input.algorithm === undefined ? 'sha256' : input.algorithm;
|
|
234
|
+
if (algorithm !== 'sha256' && algorithm !== 'sha512')
|
|
235
|
+
throw new KitError(422, 'E_INBOUND_ALGORITHM', 'الخوارزمية sha256 أو sha512');
|
|
236
|
+
const headerValue = (value, fallback) => {
|
|
237
|
+
const header = value === undefined || value === '' ? fallback : text(value).toLowerCase();
|
|
238
|
+
if (!HEADER.test(header))
|
|
239
|
+
throw new KitError(422, 'E_INBOUND_HEADER', `اسم ترويسة غير صالح: ${String(value)}`);
|
|
240
|
+
return header;
|
|
241
|
+
};
|
|
242
|
+
const dedupeBody = input.dedupeBody === undefined ? true : input.dedupeBody;
|
|
243
|
+
if (typeof dedupeBody !== 'boolean')
|
|
244
|
+
throw new KitError(422, 'E_INBOUND_DEDUPE', 'منع تكرار المحتوى يجب أن يكون صحيحاً أو خطأ');
|
|
245
|
+
const prefix = input.signaturePrefix === undefined ? `${algorithm}=` : text(input.signaturePrefix);
|
|
246
|
+
if (prefix.length > 20 || /[^\x21-\x7e]/.test(prefix))
|
|
247
|
+
throw new KitError(422, 'E_INBOUND_HEADER', 'بادئة التوقيع غير صالحة');
|
|
248
|
+
return {
|
|
249
|
+
key,
|
|
250
|
+
name,
|
|
251
|
+
algorithm,
|
|
252
|
+
signature_header: headerValue(input.signatureHeader, `x-hub-signature-${algorithm === 'sha256' ? '256' : '512'}`),
|
|
253
|
+
signature_prefix: prefix,
|
|
254
|
+
event_header: headerValue(input.eventHeader, 'x-github-event'),
|
|
255
|
+
delivery_header: headerValue(input.deliveryHeader, 'x-github-delivery'),
|
|
256
|
+
dedupe_body: dedupeBody,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
present(row) {
|
|
260
|
+
return {
|
|
261
|
+
id: Number(row.id),
|
|
262
|
+
key: String(row.key),
|
|
263
|
+
name: String(row.name),
|
|
264
|
+
algorithm: row.algorithm,
|
|
265
|
+
signatureHeader: String(row.signature_header),
|
|
266
|
+
signaturePrefix: String(row.signature_prefix),
|
|
267
|
+
eventHeader: String(row.event_header),
|
|
268
|
+
deliveryHeader: String(row.delivery_header),
|
|
269
|
+
dedupeBody: Boolean(row.dedupe_body),
|
|
270
|
+
active: Boolean(row.active),
|
|
271
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
272
|
+
lastReceivedAt: row.last_received_at ? new Date(row.last_received_at).toISOString() : null,
|
|
273
|
+
path: `/webhooks/in/${row.key}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -20,6 +20,8 @@ function fieldSchema(field, mode, registry) {
|
|
|
20
20
|
return { description };
|
|
21
21
|
case 'belongsTo':
|
|
22
22
|
return { type: 'integer', description: `${description} → ${field.resource}` };
|
|
23
|
+
case 'user':
|
|
24
|
+
return { type: 'integer', description: `${description} → user id (related: id, fullName)` };
|
|
23
25
|
case 'lookup':
|
|
24
26
|
return { type: 'string', description: `${description} (lookup group ${field.group})` };
|
|
25
27
|
case 'attachment':
|
|
@@ -89,14 +91,14 @@ function schemas(resource, registry) {
|
|
|
89
91
|
key,
|
|
90
92
|
nullable(fieldSchema(resource.fields[key], 'write', registry), Boolean(resource.fields[key].required)),
|
|
91
93
|
])),
|
|
92
|
-
...(resource.scoped ? { orgUnitId: { type: 'integer' } } : {}),
|
|
94
|
+
...(resource.scoped && !resource.scope ? { orgUnitId: { type: 'integer' } } : {}),
|
|
93
95
|
...(resource.version
|
|
94
96
|
? { version: { type: 'integer', description: 'Required on update (optimistic locking)' } }
|
|
95
97
|
: {}),
|
|
96
98
|
},
|
|
97
99
|
required: [
|
|
98
100
|
...writable.filter((key) => resource.fields[key].required),
|
|
99
|
-
...(resource.scoped ? ['orgUnitId'] : []),
|
|
101
|
+
...(resource.scoped && !resource.scope ? ['orgUnitId'] : []),
|
|
100
102
|
],
|
|
101
103
|
};
|
|
102
104
|
return { read, input };
|
|
@@ -214,6 +216,71 @@ export function openApiDocument(registry, options) {
|
|
|
214
216
|
parameters: [idParam],
|
|
215
217
|
responses: { 200: one('Deleted'), 403: error, 404: error, 409: error },
|
|
216
218
|
};
|
|
219
|
+
if (allowed.has('view'))
|
|
220
|
+
paths[`${base}/resources/${resource.name}/aggregate`] = {
|
|
221
|
+
get: {
|
|
222
|
+
tags: [tag],
|
|
223
|
+
summary: `Count and total ${resource.label.en} by group`,
|
|
224
|
+
description: 'Only records the token may view are counted; grouped, totalled and filtered fields must be readable without conditions.',
|
|
225
|
+
parameters: [
|
|
226
|
+
{
|
|
227
|
+
name: 'groupBy',
|
|
228
|
+
in: 'query',
|
|
229
|
+
description: 'Up to three comma-separated fields',
|
|
230
|
+
schema: { type: 'string' },
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'sum',
|
|
234
|
+
in: 'query',
|
|
235
|
+
description: 'Comma-separated integer or money fields',
|
|
236
|
+
schema: { type: 'string' },
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'where',
|
|
240
|
+
in: 'query',
|
|
241
|
+
description: 'JSON conditions ($eq, $ne, $in, $lt, $gt, $like)',
|
|
242
|
+
schema: { type: 'string' },
|
|
243
|
+
},
|
|
244
|
+
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
245
|
+
],
|
|
246
|
+
responses: {
|
|
247
|
+
200: {
|
|
248
|
+
description: 'Groups (at most 1000)',
|
|
249
|
+
content: {
|
|
250
|
+
'application/json': {
|
|
251
|
+
schema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
data: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: {
|
|
257
|
+
truncated: { type: 'boolean' },
|
|
258
|
+
rows: {
|
|
259
|
+
type: 'array',
|
|
260
|
+
items: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
group: { type: 'object' },
|
|
264
|
+
count: { type: 'integer' },
|
|
265
|
+
sum: {
|
|
266
|
+
type: 'object',
|
|
267
|
+
additionalProperties: { type: ['string', 'null'] },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
403: error,
|
|
280
|
+
422: error,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
};
|
|
217
284
|
if (Object.keys(collection).length)
|
|
218
285
|
paths[`${base}/resources/${resource.name}`] = collection;
|
|
219
286
|
if (Object.keys(item).length)
|
|
@@ -20,6 +20,7 @@ function text(field, value, input) {
|
|
|
20
20
|
}
|
|
21
21
|
case 'lookup':
|
|
22
22
|
return (input.lookups?.[field.key]?.find((option) => option.value === value)?.label ?? String(value));
|
|
23
|
+
case 'user':
|
|
23
24
|
case 'belongsTo': {
|
|
24
25
|
const related = input.related?.[field.key]?.find((row) => row.id === value);
|
|
25
26
|
const label = related ? Object.entries(related).find(([key]) => key !== 'id')?.[1] : null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertTitle } from '../admin/record_title.js';
|
|
1
2
|
export function identifier(value) {
|
|
2
3
|
if (!/^[a-z][a-z0-9_]*$/.test(value))
|
|
3
4
|
throw new Error(`Unsafe SQL identifier: ${value}`);
|
|
@@ -20,6 +21,9 @@ export function defineResource(input) {
|
|
|
20
21
|
throw new Error(`${input.name}: scoped must be explicit`);
|
|
21
22
|
if (!input.label.ar || !input.label.en)
|
|
22
23
|
throw new Error('Bilingual labels are required');
|
|
24
|
+
for (const optional of [input.recordLabel, input.createLabel])
|
|
25
|
+
if (optional && (!optional.ar || !optional.en))
|
|
26
|
+
throw new Error(`${input.name}: recordLabel and createLabel need Arabic and English`);
|
|
23
27
|
for (const [key, field] of Object.entries(input.fields)) {
|
|
24
28
|
columnName(key);
|
|
25
29
|
identifier(field.column ?? columnName(key));
|
|
@@ -50,6 +54,14 @@ export function defineResource(input) {
|
|
|
50
54
|
if (!(key in input.fields))
|
|
51
55
|
throw new Error(`Unknown field: ${key}`);
|
|
52
56
|
}
|
|
57
|
+
if (input.scope) {
|
|
58
|
+
const parent = input.fields[input.scope.from];
|
|
59
|
+
if (!input.scoped)
|
|
60
|
+
throw new Error(`${input.name}: scope.from requires scoped: true`);
|
|
61
|
+
if (parent?.type !== 'belongsTo' || !parent.required)
|
|
62
|
+
throw new Error(`${input.name}: scope.from must name a required belongsTo field`);
|
|
63
|
+
}
|
|
64
|
+
assertTitle(input);
|
|
53
65
|
if (!input.submittable && input.actions.some((a) => ['submit', 'cancel', 'amend'].includes(a)))
|
|
54
66
|
throw new Error('Document actions require submittable');
|
|
55
67
|
return Object.freeze({ ...input, version: input.version ?? input.submittable ?? false });
|
|
@@ -20,7 +20,37 @@ export class ResourceRegistry {
|
|
|
20
20
|
next.#owners.set(resource.name, module.name);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
const lookupKeys = new Set();
|
|
24
|
+
const roleKeys = new Set();
|
|
25
|
+
for (const module of next.#modules.values()) {
|
|
26
|
+
for (const [group, rows] of Object.entries(module.lookups ?? {})) {
|
|
27
|
+
identifier(group);
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (typeof row.key !== 'string' || !row.key.trim() || row.key.length > 255)
|
|
30
|
+
throw new Error(`Module ${module.name}: invalid lookup key in ${group}`);
|
|
31
|
+
if (!row.label?.ar || !row.label?.en)
|
|
32
|
+
throw new Error(`Module ${module.name}: lookup ${group}.${row.key} needs bilingual labels`);
|
|
33
|
+
if (lookupKeys.has(`${group}\u0000${row.key}`))
|
|
34
|
+
throw new Error(`Duplicate lookup: ${group}.${row.key}`);
|
|
35
|
+
lookupKeys.add(`${group}\u0000${row.key}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const role of module.defaultRoles ?? []) {
|
|
39
|
+
if (!/^[a-z][a-z0-9_]{0,99}$/.test(role.key))
|
|
40
|
+
throw new Error(`Module ${module.name}: invalid role key ${role.key}`);
|
|
41
|
+
if (roleKeys.has(role.key))
|
|
42
|
+
throw new Error(`Duplicate default role: ${role.key}`);
|
|
43
|
+
if (!role.name?.trim())
|
|
44
|
+
throw new Error(`Module ${module.name}: role ${role.key} needs a name`);
|
|
45
|
+
roleKeys.add(role.key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
23
48
|
for (const resource of next.all()) {
|
|
49
|
+
if (resource.scope) {
|
|
50
|
+
const field = resource.fields[resource.scope.from];
|
|
51
|
+
if (field.type === 'belongsTo' && !next.get(field.resource).scoped)
|
|
52
|
+
throw new Error(`${resource.name}: scope.from must reference a scoped resource`);
|
|
53
|
+
}
|
|
24
54
|
for (const field of Object.values(resource.fields)) {
|
|
25
55
|
if (field.type !== 'belongsTo' && field.type !== 'hasMany')
|
|
26
56
|
continue;
|
|
@@ -46,6 +76,9 @@ export class ResourceRegistry {
|
|
|
46
76
|
throw new Error(`Unknown resource: ${name}`);
|
|
47
77
|
return resource;
|
|
48
78
|
}
|
|
79
|
+
has(name) {
|
|
80
|
+
return this.#resources.has(name);
|
|
81
|
+
}
|
|
49
82
|
all() {
|
|
50
83
|
return [...this.#resources.values()];
|
|
51
84
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Knex } from 'knex';
|
|
2
2
|
import type { LucidModel } from '@adonisjs/lucid/types/model';
|
|
3
3
|
import type { WorkflowDefinition } from '../workflows/define_workflow.js';
|
|
4
|
+
import type { Conditions } from '../auth/conditions.js';
|
|
4
5
|
export type Label = {
|
|
5
6
|
ar: string;
|
|
6
7
|
en: string;
|
|
@@ -36,6 +37,13 @@ export type Field = {
|
|
|
36
37
|
} | {
|
|
37
38
|
type: 'belongsTo';
|
|
38
39
|
resource: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A user of this deployment (foreign key to users). Choices are active members of
|
|
43
|
+
* the record's organization unit or its ancestors; readers see only the display name.
|
|
44
|
+
*/
|
|
45
|
+
| {
|
|
46
|
+
type: 'user';
|
|
39
47
|
} | {
|
|
40
48
|
type: 'hasMany';
|
|
41
49
|
resource: string;
|
|
@@ -47,9 +55,26 @@ export type Field = {
|
|
|
47
55
|
});
|
|
48
56
|
export type Resource = {
|
|
49
57
|
name: string;
|
|
58
|
+
/** The list heading, usually plural (for example «الحسابات»). */
|
|
50
59
|
label: Label;
|
|
60
|
+
/**
|
|
61
|
+
* The singular record noun (for example «حساب»). Generated pages say «إضافة حساب»
|
|
62
|
+
* instead of the generic «إضافة سجل».
|
|
63
|
+
*/
|
|
64
|
+
recordLabel?: Label;
|
|
65
|
+
/** Full create-button text when «إضافة <recordLabel>» does not fit (for example «مهمة جديدة»). */
|
|
66
|
+
createLabel?: Label;
|
|
51
67
|
model: LucidModel;
|
|
52
68
|
scoped: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The organization unit of a scoped record follows a required belongsTo parent, for
|
|
71
|
+
* example a clinic follows its facility. The unit is copied from the parent before
|
|
72
|
+
* authorization, the actor must be able to view the parent, and the form has no unit
|
|
73
|
+
* picker. `ResourceService.rehome` moves the children after the parent moves.
|
|
74
|
+
*/
|
|
75
|
+
scope?: {
|
|
76
|
+
from: string;
|
|
77
|
+
};
|
|
53
78
|
submittable?: boolean;
|
|
54
79
|
version?: boolean;
|
|
55
80
|
customFields?: boolean;
|
|
@@ -59,6 +84,12 @@ export type Resource = {
|
|
|
59
84
|
show: readonly string[];
|
|
60
85
|
serialize?: readonly string[];
|
|
61
86
|
hidden?: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* Fields that name a record wherever it is referenced: relation cells and pickers,
|
|
89
|
+
* "My tasks" and the approvals inbox. Joined with « · ». Defaults to the first
|
|
90
|
+
* sequence field and the first text field in `list`.
|
|
91
|
+
*/
|
|
92
|
+
title?: readonly string[];
|
|
62
93
|
actions: readonly Action[];
|
|
63
94
|
validator: {
|
|
64
95
|
validate(data: unknown): Promise<RecordData>;
|
|
@@ -73,6 +104,27 @@ export type HookContext = {
|
|
|
73
104
|
userId: number;
|
|
74
105
|
action: Action;
|
|
75
106
|
};
|
|
107
|
+
/** A changeable list value a module needs before its records can be saved. */
|
|
108
|
+
export type ModuleLookup = {
|
|
109
|
+
key: string;
|
|
110
|
+
label: Label;
|
|
111
|
+
sort?: number;
|
|
112
|
+
};
|
|
113
|
+
/** A role a module ships; its rules go through the same validation as the roles screen. */
|
|
114
|
+
export type ModuleRole = {
|
|
115
|
+
/** Stable key that workflows address ({ role: key }); see roles.key. */
|
|
116
|
+
key: string;
|
|
117
|
+
/** Arabic display name; administrators may rename it later. */
|
|
118
|
+
name: string;
|
|
119
|
+
permissionLevel?: number;
|
|
120
|
+
rules: readonly {
|
|
121
|
+
subject: string;
|
|
122
|
+
action: string;
|
|
123
|
+
inverted?: boolean;
|
|
124
|
+
conditions?: Conditions | null;
|
|
125
|
+
fields?: string[] | null;
|
|
126
|
+
}[];
|
|
127
|
+
};
|
|
76
128
|
export type Module = {
|
|
77
129
|
name: string;
|
|
78
130
|
reference?: boolean;
|
|
@@ -81,12 +133,23 @@ export type Module = {
|
|
|
81
133
|
resources: readonly Resource[];
|
|
82
134
|
/** Versioned workflows of this module's submittable resources (phase 4). */
|
|
83
135
|
workflows?: readonly WorkflowDefinition[];
|
|
136
|
+
/**
|
|
137
|
+
* Lookup rows by group. adula:install inserts missing rows and never changes or
|
|
138
|
+
* removes existing ones, so administrator edits survive upgrades.
|
|
139
|
+
*/
|
|
140
|
+
lookups?: Readonly<Record<string, readonly ModuleLookup[]>>;
|
|
141
|
+
/**
|
|
142
|
+
* Roles created by adula:install when no role has their key yet. An existing role
|
|
143
|
+
* is never modified, so administrators own every role after installation.
|
|
144
|
+
*/
|
|
145
|
+
defaultRoles?: readonly ModuleRole[];
|
|
84
146
|
};
|
|
85
|
-
export type ResourceInput<F extends Record<string, Field>> = Omit<Resource, 'fields' | 'list' | 'form' | 'show' | 'hidden' | 'serialize'> & {
|
|
147
|
+
export type ResourceInput<F extends Record<string, Field>> = Omit<Resource, 'fields' | 'list' | 'form' | 'show' | 'hidden' | 'serialize' | 'title'> & {
|
|
86
148
|
fields: F;
|
|
87
149
|
list: readonly (keyof F & string)[];
|
|
88
150
|
form: readonly (keyof F & string)[];
|
|
89
151
|
show: readonly (keyof F & string)[];
|
|
90
152
|
hidden?: readonly (keyof F & string)[];
|
|
91
153
|
serialize?: readonly (keyof F & string)[];
|
|
154
|
+
title?: readonly (keyof F & string)[];
|
|
92
155
|
};
|
|
@@ -48,8 +48,9 @@ export function fieldValue(field, value) {
|
|
|
48
48
|
}
|
|
49
49
|
case 'integer':
|
|
50
50
|
case 'belongsTo':
|
|
51
|
+
case 'user':
|
|
51
52
|
if (!Number.isInteger(value) ||
|
|
52
|
-
Number(value) < (field.type === '
|
|
53
|
+
Number(value) < (field.type === 'integer' ? -2147483648 : 1) ||
|
|
53
54
|
Number(value) > 2147483647)
|
|
54
55
|
throw new Error('Expected a PostgreSQL integer');
|
|
55
56
|
return value;
|
|
@@ -6,4 +6,16 @@ export declare class Settings {
|
|
|
6
6
|
set(key: string, value: unknown, scope?: string, scopeId?: string): Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
export declare function sequence(trx: Knex.Transaction, key: string): Promise<string>;
|
|
9
|
-
|
|
9
|
+
/** The record a notification is about; opening the notification opens the record. */
|
|
10
|
+
export type NotificationTarget = {
|
|
11
|
+
resource: string;
|
|
12
|
+
recordId: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function targetColumns(target?: NotificationTarget | null): {
|
|
15
|
+
resource?: undefined;
|
|
16
|
+
record_id?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
resource: string;
|
|
19
|
+
record_id: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function notify(db: Knex, userId: number, title: string, body: string, target?: NotificationTarget | null): Promise<void>;
|
|
@@ -18,6 +18,13 @@ export async function sequence(trx, key) {
|
|
|
18
18
|
const result = await trx.raw('INSERT INTO sequences (key,value) VALUES (?,1) ON CONFLICT (key) DO UPDATE SET value=sequences.value+1 RETURNING value', [key]);
|
|
19
19
|
return `${key}-${String(result.rows[0].value).padStart(6, '0')}`;
|
|
20
20
|
}
|
|
21
|
-
export
|
|
22
|
-
|
|
21
|
+
export function targetColumns(target) {
|
|
22
|
+
if (!target)
|
|
23
|
+
return {};
|
|
24
|
+
if (!/^[a-z][a-z0-9_]*$/.test(target.resource) || !Number.isSafeInteger(target.recordId))
|
|
25
|
+
throw new Error('Invalid notification target');
|
|
26
|
+
return { resource: target.resource, record_id: target.recordId };
|
|
27
|
+
}
|
|
28
|
+
export async function notify(db, userId, title, body, target) {
|
|
29
|
+
await db('notifications').insert({ user_id: userId, title, body, ...targetColumns(target) });
|
|
23
30
|
}
|