@adula/kit 0.2.0-alpha.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/build/agent/AGENTS.template.md +2 -2
- package/build/agent/capabilities.md +54 -7
- package/build/agent/skills/adula-frontend-design/SKILL.md +1 -1
- package/build/agent/skills/idea-review/SKILL.md +26 -1
- package/build/agent/skills/module-review/SKILL.md +22 -0
- package/build/agent/skills/perf-review/SKILL.md +24 -0
- package/build/agent/skills/schema-review/SKILL.md +22 -0
- package/build/agent/skills/security-review/SKILL.md +24 -0
- package/build/agent/skills/ui-review/SKILL.md +22 -0
- package/build/commands/capabilities.d.ts +4 -0
- package/build/commands/capabilities.js +35 -4
- 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.d.ts +4 -2
- 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/1770000000004_kit_collaboration.d.ts +5 -0
- package/build/database/migrations/1770000000004_kit_collaboration.js +10 -0
- package/build/database/migrations/1770000000005_kit_assignments.d.ts +5 -0
- package/build/database/migrations/1770000000005_kit_assignments.js +10 -0
- package/build/database/migrations/1770000000006_kit_messaging.d.ts +5 -0
- package/build/database/migrations/1770000000006_kit_messaging.js +10 -0
- package/build/database/migrations/1770000000007_kit_webhooks.d.ts +5 -0
- package/build/database/migrations/1770000000007_kit_webhooks.js +10 -0
- package/build/database/migrations/1770000000008_kit_imports.d.ts +5 -0
- package/build/database/migrations/1770000000008_kit_imports.js +10 -0
- package/build/database/migrations/1770000000010_kit_workflows.d.ts +5 -0
- package/build/database/migrations/1770000000010_kit_workflows.js +10 -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 +31 -2
- package/build/index.js +17 -2
- package/build/src/admin/contracts.d.ts +7 -0
- package/build/src/admin/contracts.js +34 -12
- package/build/src/admin/controller.d.ts +2 -0
- package/build/src/admin/controller.js +52 -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 +184 -2
- package/build/src/admin/resource_service.js +713 -48
- 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 +129 -0
- package/build/src/collaboration/assignments.js +333 -0
- package/build/src/collaboration/record_collaboration.d.ts +86 -0
- package/build/src/collaboration/record_collaboration.js +348 -0
- package/build/src/commands/agent_assets.js +5 -0
- package/build/src/commands/capabilities.d.ts +19 -0
- package/build/src/commands/capabilities.js +179 -0
- 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 +83 -0
- package/build/src/core/message_templates.js +293 -0
- 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 +45 -0
- package/build/src/database/schema.js +290 -0
- package/build/src/eslint/index.js +26 -0
- package/build/src/events/outbox.d.ts +1 -0
- package/build/src/events/outbox.js +1 -1
- package/build/src/events/record_mutation.d.ts +11 -0
- package/build/src/events/record_mutation.js +15 -2
- package/build/src/integrations/imports.d.ts +74 -0
- package/build/src/integrations/imports.js +333 -0
- package/build/src/integrations/inbound_webhooks.d.ts +94 -0
- package/build/src/integrations/inbound_webhooks.js +276 -0
- package/build/src/integrations/openapi.d.ts +39 -0
- package/build/src/integrations/openapi.js +323 -0
- package/build/src/integrations/print.d.ts +37 -0
- package/build/src/integrations/print.js +124 -0
- package/build/src/integrations/webhooks.d.ts +98 -0
- package/build/src/integrations/webhooks.js +298 -0
- package/build/src/resource/define_resource.d.ts +1 -0
- package/build/src/resource/define_resource.js +21 -1
- package/build/src/resource/registry.d.ts +3 -0
- package/build/src/resource/registry.js +42 -0
- package/build/src/resource/types.d.ts +67 -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 +124 -0
- package/build/src/workflows/define_workflow.js +123 -0
- package/build/src/workflows/engine.d.ts +141 -0
- package/build/src/workflows/engine.js +752 -0
- package/build/stubs/resource_contract.txt +103 -41
- package/package.json +7 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Actor } from '../auth/ability.js';
|
|
2
|
+
import type { ResourceRegistry } from '../resource/registry.js';
|
|
3
|
+
type Schema = Record<string, unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* OpenAPI 3.1 description of the resource API, generated from the registry. When
|
|
6
|
+
* an actor is given, only resources and actions it may use are described.
|
|
7
|
+
*/
|
|
8
|
+
export declare function openApiDocument(registry: ResourceRegistry, options: {
|
|
9
|
+
title: string;
|
|
10
|
+
version: string;
|
|
11
|
+
serverUrl: string;
|
|
12
|
+
basePath?: string;
|
|
13
|
+
/** Describe only what this actor may use (row conditions still apply at runtime). */
|
|
14
|
+
actor?: Actor;
|
|
15
|
+
}): {
|
|
16
|
+
openapi: string;
|
|
17
|
+
info: {
|
|
18
|
+
title: string;
|
|
19
|
+
version: string;
|
|
20
|
+
};
|
|
21
|
+
servers: {
|
|
22
|
+
url: string;
|
|
23
|
+
}[];
|
|
24
|
+
security: {
|
|
25
|
+
bearer: never[];
|
|
26
|
+
}[];
|
|
27
|
+
paths: Record<string, Schema>;
|
|
28
|
+
components: {
|
|
29
|
+
schemas: Record<string, Schema>;
|
|
30
|
+
securitySchemes: {
|
|
31
|
+
bearer: {
|
|
32
|
+
type: string;
|
|
33
|
+
scheme: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { buildAbility } from '../auth/ability.js';
|
|
2
|
+
function fieldSchema(field, mode, registry) {
|
|
3
|
+
const description = field.label.ar;
|
|
4
|
+
switch (field.type) {
|
|
5
|
+
case 'integer':
|
|
6
|
+
return { type: 'integer', description };
|
|
7
|
+
case 'money':
|
|
8
|
+
return {
|
|
9
|
+
type: 'string',
|
|
10
|
+
pattern: '^-?\\d+$',
|
|
11
|
+
description: `${description} (minor units as a decimal string)`,
|
|
12
|
+
};
|
|
13
|
+
case 'boolean':
|
|
14
|
+
return { type: 'boolean', description };
|
|
15
|
+
case 'date':
|
|
16
|
+
return { type: 'string', format: 'date', description };
|
|
17
|
+
case 'datetime':
|
|
18
|
+
return { type: 'string', format: 'date-time', description };
|
|
19
|
+
case 'json':
|
|
20
|
+
return { description };
|
|
21
|
+
case 'belongsTo':
|
|
22
|
+
return { type: 'integer', description: `${description} → ${field.resource}` };
|
|
23
|
+
case 'user':
|
|
24
|
+
return { type: 'integer', description: `${description} → user id (related: id, fullName)` };
|
|
25
|
+
case 'lookup':
|
|
26
|
+
return { type: 'string', description: `${description} (lookup group ${field.group})` };
|
|
27
|
+
case 'attachment':
|
|
28
|
+
return mode === 'write'
|
|
29
|
+
? { type: 'integer', description: `${description} (id returned by POST /attachments)` }
|
|
30
|
+
: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
description,
|
|
33
|
+
properties: {
|
|
34
|
+
id: { type: 'integer' },
|
|
35
|
+
name: { type: 'string' },
|
|
36
|
+
size: { type: 'integer' },
|
|
37
|
+
mimeType: { type: 'string' },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
case 'hasMany': {
|
|
41
|
+
const child = registry.get(field.resource);
|
|
42
|
+
return {
|
|
43
|
+
type: 'array',
|
|
44
|
+
maxItems: 100,
|
|
45
|
+
description,
|
|
46
|
+
items: { $ref: `#/components/schemas/${child.name}_input` },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
default:
|
|
50
|
+
return { type: 'string', description };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function nullable(schema, required) {
|
|
54
|
+
if (required)
|
|
55
|
+
return schema;
|
|
56
|
+
if (typeof schema.type === 'string')
|
|
57
|
+
return { ...schema, type: [schema.type, 'null'] };
|
|
58
|
+
return schema;
|
|
59
|
+
}
|
|
60
|
+
function schemas(resource, registry) {
|
|
61
|
+
const readKeys = (resource.serialize ?? [...new Set([...resource.list, ...resource.show])]).filter((key) => resource.fields[key]?.type !== 'hasMany');
|
|
62
|
+
const read = {
|
|
63
|
+
type: 'object',
|
|
64
|
+
description: `${resource.label.ar}. Fields the caller may not read are omitted.`,
|
|
65
|
+
properties: {
|
|
66
|
+
id: { type: 'integer' },
|
|
67
|
+
...Object.fromEntries(readKeys.map((key) => [
|
|
68
|
+
key,
|
|
69
|
+
nullable(fieldSchema(resource.fields[key], 'read', registry), false),
|
|
70
|
+
])),
|
|
71
|
+
...(resource.version ? { version: { type: 'integer' } } : {}),
|
|
72
|
+
...(resource.submittable
|
|
73
|
+
? {
|
|
74
|
+
docStatus: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
enum: [0, 1, 2],
|
|
77
|
+
description: '0 draft, 1 submitted, 2 cancelled',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
...(resource.scoped ? { orgUnitId: { type: 'integer' } } : {}),
|
|
82
|
+
},
|
|
83
|
+
required: ['id'],
|
|
84
|
+
};
|
|
85
|
+
const writable = resource.form.filter((key) => !resource.fields[key].sequence);
|
|
86
|
+
const input = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
properties: {
|
|
90
|
+
...Object.fromEntries(writable.map((key) => [
|
|
91
|
+
key,
|
|
92
|
+
nullable(fieldSchema(resource.fields[key], 'write', registry), Boolean(resource.fields[key].required)),
|
|
93
|
+
])),
|
|
94
|
+
...(resource.scoped && !resource.scope ? { orgUnitId: { type: 'integer' } } : {}),
|
|
95
|
+
...(resource.version
|
|
96
|
+
? { version: { type: 'integer', description: 'Required on update (optimistic locking)' } }
|
|
97
|
+
: {}),
|
|
98
|
+
},
|
|
99
|
+
required: [
|
|
100
|
+
...writable.filter((key) => resource.fields[key].required),
|
|
101
|
+
...(resource.scoped && !resource.scope ? ['orgUnitId'] : []),
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
return { read, input };
|
|
105
|
+
}
|
|
106
|
+
const error = {
|
|
107
|
+
description: 'Error',
|
|
108
|
+
content: {
|
|
109
|
+
'application/json': {
|
|
110
|
+
schema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
error: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: { code: { type: 'string' }, message: { type: 'string' } },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* OpenAPI 3.1 description of the resource API, generated from the registry. When
|
|
124
|
+
* an actor is given, only resources and actions it may use are described.
|
|
125
|
+
*/
|
|
126
|
+
export function openApiDocument(registry, options) {
|
|
127
|
+
const base = options.basePath ?? '/api/v1';
|
|
128
|
+
const paths = {};
|
|
129
|
+
const components = {};
|
|
130
|
+
const children = new Set(registry
|
|
131
|
+
.all()
|
|
132
|
+
.flatMap((resource) => Object.values(resource.fields).flatMap((field) => field.type === 'hasMany' ? [field.resource] : [])));
|
|
133
|
+
for (const resource of registry.all()) {
|
|
134
|
+
const ability = options.actor ? buildAbility(options.actor.rules, registry.all()) : undefined;
|
|
135
|
+
const allowed = new Set(resource.actions.filter((action) => !ability || ability.can(action, resource.name)));
|
|
136
|
+
if (ability && !allowed.has('view'))
|
|
137
|
+
continue;
|
|
138
|
+
const { read, input } = schemas(resource, registry);
|
|
139
|
+
components[resource.name] = read;
|
|
140
|
+
components[`${resource.name}_input`] = input;
|
|
141
|
+
if (children.has(resource.name))
|
|
142
|
+
continue;
|
|
143
|
+
const ref = { $ref: `#/components/schemas/${resource.name}` };
|
|
144
|
+
const inputRef = { $ref: `#/components/schemas/${resource.name}_input` };
|
|
145
|
+
const tag = resource.label.en;
|
|
146
|
+
const one = (description) => ({
|
|
147
|
+
description,
|
|
148
|
+
content: { 'application/json': { schema: { type: 'object', properties: { data: ref } } } },
|
|
149
|
+
});
|
|
150
|
+
const idParam = { name: 'id', in: 'path', required: true, schema: { type: 'integer' } };
|
|
151
|
+
const collection = {};
|
|
152
|
+
const item = {};
|
|
153
|
+
if (allowed.has('view')) {
|
|
154
|
+
collection.get = {
|
|
155
|
+
tags: [tag],
|
|
156
|
+
summary: `List ${resource.label.en}`,
|
|
157
|
+
parameters: [
|
|
158
|
+
{ name: 'limit', in: 'query', schema: { type: 'integer', maximum: 100 } },
|
|
159
|
+
{ name: 'cursor', in: 'query', schema: { type: 'string' } },
|
|
160
|
+
{ name: 'search', in: 'query', schema: { type: 'string' } },
|
|
161
|
+
{ name: 'sort', in: 'query', schema: { type: 'string' } },
|
|
162
|
+
{ name: 'direction', in: 'query', schema: { enum: ['asc', 'desc'] } },
|
|
163
|
+
{ name: 'tag', in: 'query', schema: { type: 'string' } },
|
|
164
|
+
],
|
|
165
|
+
responses: {
|
|
166
|
+
200: {
|
|
167
|
+
description: 'Keyset page',
|
|
168
|
+
content: {
|
|
169
|
+
'application/json': {
|
|
170
|
+
schema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
data: { type: 'array', items: ref },
|
|
174
|
+
meta: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
limit: { type: 'integer' },
|
|
178
|
+
nextCursor: { type: ['string', 'null'] },
|
|
179
|
+
estimatedTotal: { type: 'integer' },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
403: error,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
item.get = {
|
|
191
|
+
tags: [tag],
|
|
192
|
+
summary: `Show one ${resource.label.en} record`,
|
|
193
|
+
parameters: [idParam],
|
|
194
|
+
responses: { 200: one('Record'), 403: error, 404: error },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (allowed.has('create'))
|
|
198
|
+
collection.post = {
|
|
199
|
+
tags: [tag],
|
|
200
|
+
summary: `Create ${resource.label.en}`,
|
|
201
|
+
requestBody: { required: true, content: { 'application/json': { schema: inputRef } } },
|
|
202
|
+
responses: { 201: one('Created'), 403: error, 409: error, 422: error },
|
|
203
|
+
};
|
|
204
|
+
if (allowed.has('update'))
|
|
205
|
+
item.patch = {
|
|
206
|
+
tags: [tag],
|
|
207
|
+
summary: `Update ${resource.label.en}`,
|
|
208
|
+
parameters: [idParam],
|
|
209
|
+
requestBody: { required: true, content: { 'application/json': { schema: inputRef } } },
|
|
210
|
+
responses: { 200: one('Updated'), 403: error, 404: error, 409: error, 422: error },
|
|
211
|
+
};
|
|
212
|
+
if (allowed.has('delete'))
|
|
213
|
+
item.delete = {
|
|
214
|
+
tags: [tag],
|
|
215
|
+
summary: `Soft-delete ${resource.label.en}`,
|
|
216
|
+
parameters: [idParam],
|
|
217
|
+
responses: { 200: one('Deleted'), 403: error, 404: error, 409: error },
|
|
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
|
+
};
|
|
284
|
+
if (Object.keys(collection).length)
|
|
285
|
+
paths[`${base}/resources/${resource.name}`] = collection;
|
|
286
|
+
if (Object.keys(item).length)
|
|
287
|
+
paths[`${base}/resources/${resource.name}/{id}`] = item;
|
|
288
|
+
for (const action of ['submit', 'cancel'])
|
|
289
|
+
if (resource.submittable && allowed.has(action))
|
|
290
|
+
paths[`${base}/resources/${resource.name}/{id}/${action}`] = {
|
|
291
|
+
post: {
|
|
292
|
+
tags: [tag],
|
|
293
|
+
summary: `${action === 'submit' ? 'Submit' : 'Cancel'} ${resource.label.en}`,
|
|
294
|
+
parameters: [idParam],
|
|
295
|
+
requestBody: {
|
|
296
|
+
content: {
|
|
297
|
+
'application/json': {
|
|
298
|
+
schema: { type: 'object', properties: { version: { type: 'integer' } } },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
responses: { 200: one('Transitioned'), 403: error, 404: error, 409: error },
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
openapi: '3.1.0',
|
|
308
|
+
info: { title: options.title, version: options.version },
|
|
309
|
+
servers: [{ url: options.serverUrl }],
|
|
310
|
+
security: [{ bearer: [] }],
|
|
311
|
+
paths,
|
|
312
|
+
components: {
|
|
313
|
+
schemas: components,
|
|
314
|
+
securitySchemes: {
|
|
315
|
+
bearer: {
|
|
316
|
+
type: 'http',
|
|
317
|
+
scheme: 'bearer',
|
|
318
|
+
description: 'Personal API token. Read tokens are limited to GET requests.',
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ResourceDescription } from '../admin/presentation.js';
|
|
2
|
+
import type { SerializedRecord } from '../resource/types.js';
|
|
3
|
+
export type PrintIdentity = {
|
|
4
|
+
name: string;
|
|
5
|
+
logoUrl?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type PrintInput = {
|
|
8
|
+
identity: PrintIdentity;
|
|
9
|
+
resource: ResourceDescription;
|
|
10
|
+
record: SerializedRecord;
|
|
11
|
+
related?: Record<string, SerializedRecord[]>;
|
|
12
|
+
lookups?: Record<string, {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}[]>;
|
|
16
|
+
children?: Record<string, {
|
|
17
|
+
rows: SerializedRecord[];
|
|
18
|
+
fields: ResourceDescription['fields'];
|
|
19
|
+
}>;
|
|
20
|
+
printedBy: string;
|
|
21
|
+
printedAt?: Date;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The generic printable view of one record: RTL A4 HTML, every value escaped.
|
|
25
|
+
* Only fields already serialized for the caller are printed, so printing can
|
|
26
|
+
* never reveal more than the detail page.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renderPrintHtml(input: PrintInput): string;
|
|
29
|
+
/**
|
|
30
|
+
* Converts printable HTML to PDF with an optional Gotenberg service
|
|
31
|
+
* (POST /forms/chromium/convert/html). Without it, pages use the browser's print.
|
|
32
|
+
*/
|
|
33
|
+
export declare function htmlToPdf(html: string, options: {
|
|
34
|
+
gotenbergUrl: string;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
fetch?: typeof fetch;
|
|
37
|
+
}): Promise<Buffer<ArrayBuffer>>;
|