shutro-erp 0.3.1 → 0.3.3

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 (168) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +29 -1
  3. package/dist/account_portal/index.js +3 -2
  4. package/dist/accounting/.kosh/bindings.js +1 -1
  5. package/dist/accounting/.kosh/schema.d.ts +29 -1
  6. package/dist/accounting/index.js +4 -0
  7. package/dist/assistant/index.js +1 -1
  8. package/dist/cli/init.js +2 -0
  9. package/dist/cli/launch.js +8 -5
  10. package/dist/cli/templates.js +4 -1
  11. package/dist/cli/ui.js +19 -1
  12. package/dist/contacts/.kosh/bindings.js +1 -1
  13. package/dist/contacts/.kosh/schema.d.ts +29 -1
  14. package/dist/integrations/inbound.d.ts +36 -0
  15. package/dist/integrations/inbound.js +171 -0
  16. package/dist/integrations/index.d.ts +18 -0
  17. package/dist/integrations/index.js +400 -0
  18. package/dist/integrations/models.d.ts +6 -0
  19. package/dist/integrations/models.js +119 -0
  20. package/dist/integrations/web/DeliveryTools.d.ts +4 -0
  21. package/dist/integrations/web/DeliveryTools.js +15 -0
  22. package/dist/integrations/web/EndpointTools.d.ts +7 -0
  23. package/dist/integrations/web/EndpointTools.js +25 -0
  24. package/dist/integrations/web/InboundTools.d.ts +4 -0
  25. package/dist/integrations/web/InboundTools.js +15 -0
  26. package/dist/integrations/web/WebhookTools.d.ts +7 -0
  27. package/dist/integrations/web/WebhookTools.js +25 -0
  28. package/dist/integrations/web/index.d.ts +16 -0
  29. package/dist/integrations/web/index.js +27 -0
  30. package/dist/integrations/web/messages.d.ts +2 -0
  31. package/dist/integrations/web/messages.js +53 -0
  32. package/dist/integrations/webhooks.d.ts +63 -0
  33. package/dist/integrations/webhooks.js +217 -0
  34. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  35. package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
  36. package/dist/l10n_in/.kosh/bindings.js +1 -1
  37. package/dist/l10n_in/.kosh/schema.d.ts +29 -4
  38. package/dist/l10n_in/module.d.ts +0 -62
  39. package/dist/l10n_in/module.js +2 -4
  40. package/dist/modules-sdk/index.d.ts +1 -1
  41. package/dist/modules-sdk/manifest.d.ts +48 -0
  42. package/dist/payment/.kosh/bindings.d.ts +22 -0
  43. package/dist/payment/.kosh/bindings.js +15 -0
  44. package/dist/payment/.kosh/schema.d.ts +1581 -0
  45. package/dist/payment/bkash.d.ts +28 -0
  46. package/dist/payment/bkash.js +169 -0
  47. package/dist/payment/index.d.ts +38 -0
  48. package/dist/payment/index.js +431 -0
  49. package/dist/payment/module.d.ts +6856 -0
  50. package/dist/payment/module.js +77 -0
  51. package/dist/payment/money.d.ts +7 -0
  52. package/dist/payment/money.js +37 -0
  53. package/dist/payment/providers.d.ts +75 -0
  54. package/dist/payment/providers.js +77 -0
  55. package/dist/payment/razorpay.d.ts +12 -0
  56. package/dist/payment/razorpay.js +149 -0
  57. package/dist/payment/settle.d.ts +11 -0
  58. package/dist/payment/settle.js +59 -0
  59. package/dist/payment/sslcommerz.d.ts +12 -0
  60. package/dist/payment/sslcommerz.js +185 -0
  61. package/dist/payment/stripe.d.ts +15 -0
  62. package/dist/payment/stripe.js +182 -0
  63. package/dist/permissions/admin.d.ts +3 -0
  64. package/dist/permissions/admin.js +108 -10
  65. package/dist/permissions/index.d.ts +1 -0
  66. package/dist/permissions/index.js +3 -0
  67. package/dist/permissions/roles.d.ts +2 -0
  68. package/dist/permissions/roles.js +236 -0
  69. package/dist/portal/.kosh/bindings.js +1 -1
  70. package/dist/portal/.kosh/schema.d.ts +29 -1
  71. package/dist/portal/routes-documents.js +5 -0
  72. package/dist/product/.kosh/bindings.js +1 -1
  73. package/dist/product/.kosh/schema.d.ts +29 -1
  74. package/dist/project/.kosh/bindings.js +1 -1
  75. package/dist/project/.kosh/schema.d.ts +29 -1
  76. package/dist/project_portal/.kosh/bindings.js +1 -1
  77. package/dist/project_portal/.kosh/schema.d.ts +29 -1
  78. package/dist/purchase/.kosh/bindings.js +1 -1
  79. package/dist/purchase/.kosh/schema.d.ts +29 -1
  80. package/dist/purchase_account/.kosh/bindings.js +1 -1
  81. package/dist/purchase_account/.kosh/schema.d.ts +29 -1
  82. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  83. package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
  84. package/dist/sale_account/.kosh/bindings.js +1 -1
  85. package/dist/sale_account/.kosh/schema.d.ts +29 -1
  86. package/dist/sale_portal/.kosh/bindings.js +1 -1
  87. package/dist/sale_portal/.kosh/schema.d.ts +29 -1
  88. package/dist/sale_project/.kosh/bindings.js +1 -1
  89. package/dist/sale_project/.kosh/schema.d.ts +29 -1
  90. package/dist/sales/.kosh/bindings.js +1 -1
  91. package/dist/sales/.kosh/schema.d.ts +29 -1
  92. package/dist/sales/index.js +2 -0
  93. package/dist/server/api/rest.d.ts +6 -1
  94. package/dist/server/api/rest.js +11 -0
  95. package/dist/server/auth/base.js +22 -0
  96. package/dist/server/auth/routes.d.ts +3 -0
  97. package/dist/server/auth/routes.js +29 -0
  98. package/dist/server/auth/service.d.ts +52 -0
  99. package/dist/server/auth/service.js +168 -3
  100. package/dist/server/identity.d.ts +2 -0
  101. package/dist/server/index.d.ts +2 -2
  102. package/dist/server/index.js +1 -1
  103. package/dist/server/server.js +11 -3
  104. package/dist/settings/company.d.ts +34 -0
  105. package/dist/settings/company.js +187 -0
  106. package/dist/settings/index.d.ts +10 -10
  107. package/dist/settings/index.js +159 -30
  108. package/dist/settings/people.d.ts +16 -0
  109. package/dist/settings/people.js +505 -0
  110. package/dist/settings/security.d.ts +13 -0
  111. package/dist/settings/security.js +158 -0
  112. package/dist/settings/web/AuditPage.d.ts +8 -0
  113. package/dist/settings/web/AuditPage.js +74 -0
  114. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  115. package/dist/settings/web/CompaniesPage.js +52 -0
  116. package/dist/settings/web/CompanyPage.d.ts +9 -0
  117. package/dist/settings/web/CompanyPage.js +94 -0
  118. package/dist/settings/web/OverviewPage.d.ts +9 -0
  119. package/dist/settings/web/OverviewPage.js +66 -0
  120. package/dist/settings/web/PeoplePage.d.ts +9 -0
  121. package/dist/settings/web/PeoplePage.js +163 -0
  122. package/dist/settings/web/ProfilePage.d.ts +14 -0
  123. package/dist/settings/web/ProfilePage.js +194 -0
  124. package/dist/settings/web/RolesEditor.d.ts +35 -0
  125. package/dist/settings/web/RolesEditor.js +49 -0
  126. package/dist/settings/web/RolesPage.d.ts +9 -0
  127. package/dist/settings/web/RolesPage.js +296 -0
  128. package/dist/settings/web/SecurityPage.d.ts +8 -0
  129. package/dist/settings/web/SecurityPage.js +81 -0
  130. package/dist/settings/web/index.d.ts +9 -0
  131. package/dist/settings/web/index.js +26 -1
  132. package/dist/settings/web/messages.js +566 -0
  133. package/dist/ui/api.d.ts +4 -0
  134. package/dist/ui/app.d.ts +5 -0
  135. package/dist/ui/app.js +108 -31
  136. package/dist/ui/components/primitives.d.ts +9 -1
  137. package/dist/ui/components/primitives.js +12 -4
  138. package/dist/ui/context.d.ts +10 -1
  139. package/dist/ui/context.js +60 -2
  140. package/dist/ui/i18n/messages.d.ts +46 -0
  141. package/dist/ui/i18n/messages.js +92 -0
  142. package/dist/ui/index.d.ts +2 -1
  143. package/dist/ui/index.js +2 -1
  144. package/dist/ui/lib/accent.d.ts +13 -0
  145. package/dist/ui/lib/accent.js +36 -0
  146. package/dist/ui/shell/Activate.d.ts +6 -0
  147. package/dist/ui/shell/Activate.js +39 -0
  148. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  149. package/dist/ui/shell/CommandPalette.js +16 -2
  150. package/dist/ui/shell/Sidebar.d.ts +27 -0
  151. package/dist/ui/shell/Sidebar.js +84 -0
  152. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  153. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  154. package/dist/ui/shell/shortcuts.d.ts +3 -0
  155. package/dist/ui/shell/shortcuts.js +13 -4
  156. package/dist/ui/styles.css +70 -28
  157. package/dist/ui/views/DateField.d.ts +10 -0
  158. package/dist/ui/views/DateField.js +129 -0
  159. package/dist/ui/views/FormView.js +161 -21
  160. package/dist/ui/views/ListView.d.ts +2 -0
  161. package/dist/ui/views/ListView.js +8 -3
  162. package/dist/ui/views/StatusChip.d.ts +10 -0
  163. package/dist/ui/views/StatusChip.js +22 -0
  164. package/dist/ui/views/widgets.d.ts +3 -0
  165. package/dist/ui/views/widgets.js +26 -8
  166. package/dist/views/types.d.ts +7 -0
  167. package/dist/views/validate.js +4 -0
  168. package/package.json +14 -2
@@ -0,0 +1,400 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * `shutro-erp/integrations` (RFC 0017): the Integrations app. Step 2: outbound webhook subscriptions — events of records
4
+ * and of modules, delivered signed, retried, logged, replayable. Routes:
5
+ *
6
+ * - `GET /api/v1/webhooks/events` the events subscriptions may want here
7
+ * - `POST /api/v1/webhooks/subscriptions` `{ name, url, events, runAs? }` (URL and events checked)
8
+ * - `POST /api/v1/webhooks/subscriptions/:id/ping` a test delivery
9
+ * - `POST /api/v1/webhooks/subscriptions/:id/rotate-secret` a new signing secret
10
+ * - `POST /api/v1/webhooks/deliveries/:id/replay` the same payload, delivered again
11
+ *
12
+ * Step 3: inbound webhooks — endpoints modules declare (`inboundWebhooks`), switched on per company:
13
+ *
14
+ * - `POST /api/v1/hooks/:token` the provider's call (no sign-in; verified by the module)
15
+ * - `GET /api/v1/webhooks/hooks` the endpoints modules offer
16
+ * - `POST /api/v1/webhooks/endpoints` `{ name, hook }`: an endpoint, with its path and secret
17
+ * - `POST /api/v1/webhooks/endpoints/:id/rotate-secret` a new secret
18
+ * - `POST /api/v1/webhooks/inbound/:id/replay` a received call, handled again
19
+ *
20
+ * Subscriptions are also edited through the generated API (`webhook.subscription`); deliveries are read there.
21
+ */
22
+ import { randomUUID } from 'node:crypto';
23
+ import { defineModule } from '../modules-sdk/index.js';
24
+ import { base, HttpProblem } from '../server/index.js';
25
+ import { action, form, list } from '../views/index.js';
26
+ import { handleInbound, INBOUND_JOB, inboundHooks, receive } from './inbound.js';
27
+ import { integrationsShutro, newSecret } from './models.js';
28
+ import { CHANGE_EVENTS, CHANGE_TOPIC, DELIVER_JOB, deliver, fanOut, PING_EVENT, queue, ref, urlProblem, webhookEvents, } from './webhooks.js';
29
+ export { handleInbound, hmacVerifier, INBOUND_JOB, inboundHooks, receive, } from './inbound.js';
30
+ export { integrationsShutro, newSecret, newToken } from './models.js';
31
+ export { CHANGE_EVENTS, DELIVER_JOB, deliver, fanOut, PING_EVENT, signature, urlProblem, webhookEvents, webhookHosts, } from './webhooks.js';
32
+ const idOf = (params) => {
33
+ const id = Number(params.id);
34
+ if (!(Number.isInteger(id) && id > 0))
35
+ throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
36
+ return id;
37
+ };
38
+ /** The subscription `id` the caller may change, with its company. */
39
+ async function subscriptionOf(env, id) {
40
+ env.access.check('webhook.subscription', 'write');
41
+ const set = await ref(env, 'webhook.subscription').search({ where: { id }, limit: 1 });
42
+ const [row] = set.length === 0 ? [] : (await set.load(['company'])).read(['id', 'company']);
43
+ if (row === undefined)
44
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no webhook ${id}.`);
45
+ return { id, company: row['company'] };
46
+ }
47
+ function registerWebhookRoutes(context, integrations) {
48
+ const { app } = context;
49
+ const known = () => new Set(webhookEvents(context.registry).map((e) => e.event));
50
+ app.get('/api/v1/webhooks/events', async (request) => request.env(async (env) => {
51
+ env.access.check('webhook.subscription', 'read');
52
+ return { events: webhookEvents(context.registry) };
53
+ }));
54
+ app.post('/api/v1/webhooks/subscriptions', async (request, reply) => {
55
+ const body = (request.body ?? {});
56
+ const url = String(body['url'] ?? '').trim();
57
+ const problem = urlProblem(url);
58
+ if (problem !== null)
59
+ throw new HttpProblem(422, 'invalid-url', 'Invalid URL', problem);
60
+ const events = (Array.isArray(body['events']) ? body['events'] : String(body['events'] ?? '').split(/[\s,]+/))
61
+ .map(String)
62
+ .filter((e) => e !== '');
63
+ const unknown = events.filter((e) => !known().has(e));
64
+ if (events.length === 0 || unknown.length > 0)
65
+ throw new HttpProblem(422, 'invalid-events', 'Invalid events', events.length === 0
66
+ ? 'Choose at least one event.'
67
+ : `Unknown events: ${unknown.join(', ')}.`);
68
+ const created = await request.env(async (env) => {
69
+ const rows = await ref(env, 'webhook.subscription').create([
70
+ {
71
+ name: String(body['name'] ?? '').trim() || url,
72
+ url,
73
+ events: [...new Set(events)].join(' '),
74
+ ...(body['runAs'] === undefined ? {} : { runAs: Number(body['runAs']) }),
75
+ },
76
+ ]);
77
+ const id = rows.ids[0];
78
+ const [row] = (await ref(env, 'webhook.subscription')
79
+ .browse([id])
80
+ .load(['name', 'url', 'events', 'secret'])).read(['id', 'name', 'url', 'events', 'secret']);
81
+ return row;
82
+ }, { write: true });
83
+ return reply.status(201).send(created);
84
+ });
85
+ app.post('/api/v1/webhooks/subscriptions/:id/ping', async (request, reply) => {
86
+ const id = idOf(request.params);
87
+ const sub = await request.env((env) => subscriptionOf(env, id));
88
+ const delivery = await queue(integrations, sub.id, sub.company, PING_EVENT, {
89
+ id: randomUUID(),
90
+ event: PING_EVENT,
91
+ occurredAt: new Date().toISOString(),
92
+ data: { subscription: sub.id },
93
+ });
94
+ return reply.status(202).send({ delivery });
95
+ });
96
+ app.post('/api/v1/webhooks/subscriptions/:id/rotate-secret', async (request) => {
97
+ const id = idOf(request.params);
98
+ return request.env(async (env) => {
99
+ await subscriptionOf(env, id);
100
+ const secret = newSecret();
101
+ await ref(env, 'webhook.subscription').browse([id]).write({ secret });
102
+ return { secret };
103
+ }, { write: true });
104
+ });
105
+ app.post('/api/v1/webhooks/deliveries/:id/replay', async (request, reply) => {
106
+ const id = idOf(request.params);
107
+ const original = await request.env(async (env) => {
108
+ env.access.check('webhook.subscription', 'write');
109
+ const set = await ref(env, 'webhook.delivery').search({ where: { id }, limit: 1 });
110
+ const [row] = set.length === 0
111
+ ? []
112
+ : (await set.load(['subscription', 'event', 'payload', 'company'])).read([
113
+ 'subscription',
114
+ 'event',
115
+ 'payload',
116
+ 'company',
117
+ ]);
118
+ if (row === undefined)
119
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no delivery ${id}.`);
120
+ return row;
121
+ });
122
+ const delivery = await queue(integrations, original['subscription'], original['company'], String(original['event']), original['payload'], id);
123
+ return reply.status(202).send({ delivery });
124
+ });
125
+ }
126
+ const MAX_INBOUND = 1024 * 1024;
127
+ /** The inbound endpoint `id` the caller may change. */
128
+ async function endpointOf(env, id) {
129
+ env.access.check('webhook.endpoint', 'write');
130
+ const set = await ref(env, 'webhook.endpoint').search({ where: { id }, limit: 1 });
131
+ if (set.length === 0)
132
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no inbound webhook ${id}.`);
133
+ return id;
134
+ }
135
+ function registerInboundRoutes(context, integrations) {
136
+ const { app } = context;
137
+ // The providers' calls: no session (the module's check decides), rate limited per address; the body kept raw.
138
+ app.register(async (hook) => {
139
+ hook.removeAllContentTypeParsers();
140
+ hook.addContentTypeParser('*', { parseAs: 'string', bodyLimit: MAX_INBOUND }, (_req, body, done) => done(null, body));
141
+ hook.post('/api/v1/hooks/:token', { config: { auth: false } }, async (request, reply) => {
142
+ const token = String(request.params.token ?? '');
143
+ const body = typeof request.body === 'string' ? request.body : '';
144
+ let json = null;
145
+ try {
146
+ json = body === '' ? null : JSON.parse(body);
147
+ }
148
+ catch {
149
+ json = null;
150
+ }
151
+ const outcome = await receive(integrations, token, {
152
+ headers: Object.fromEntries(Object.entries(request.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v])),
153
+ query: Object.fromEntries(Object.entries((request.query ?? {})).map(([k, v]) => [
154
+ k,
155
+ String(v),
156
+ ])),
157
+ body,
158
+ json,
159
+ });
160
+ return reply.status(outcome.status).send(outcome.body);
161
+ });
162
+ });
163
+ app.get('/api/v1/webhooks/hooks', async (request) => request.env(async (env) => {
164
+ env.access.check('webhook.endpoint', 'read');
165
+ return {
166
+ hooks: [...inboundHooks(context.registry)].map(([hook, spec]) => ({
167
+ hook,
168
+ module: spec.module,
169
+ label: spec.label,
170
+ })),
171
+ };
172
+ }));
173
+ app.post('/api/v1/webhooks/endpoints', async (request, reply) => {
174
+ const body = (request.body ?? {});
175
+ const hook = String(body['hook'] ?? '').trim();
176
+ const spec = inboundHooks(context.registry).get(hook);
177
+ if (spec === undefined)
178
+ throw new HttpProblem(422, 'invalid-hook', 'Unknown endpoint', `No module offers the endpoint '${hook}'.`);
179
+ const created = await request.env(async (env) => {
180
+ const rows = await ref(env, 'webhook.endpoint').create([
181
+ { name: String(body['name'] ?? '').trim() || spec.label, hook },
182
+ ]);
183
+ const id = rows.ids[0];
184
+ const [row] = (await ref(env, 'webhook.endpoint').browse([id]).load(['name', 'hook', 'token', 'secret'])).read(['id', 'name', 'hook', 'token', 'secret']);
185
+ return row ?? {};
186
+ }, { write: true });
187
+ return reply
188
+ .status(201)
189
+ .send({ ...created, path: `/api/v1/hooks/${String(created['token'])}` });
190
+ });
191
+ app.post('/api/v1/webhooks/endpoints/:id/rotate-secret', async (request) => {
192
+ const id = idOf(request.params);
193
+ return request.env(async (env) => {
194
+ await endpointOf(env, id);
195
+ const secret = newSecret();
196
+ await ref(env, 'webhook.endpoint').browse([id]).write({ secret });
197
+ return { secret };
198
+ }, { write: true });
199
+ });
200
+ app.post('/api/v1/webhooks/inbound/:id/replay', async (request, reply) => {
201
+ const id = idOf(request.params);
202
+ await request.env(async (env) => {
203
+ env.access.check('webhook.endpoint', 'write');
204
+ const set = await ref(env, 'webhook.inbound').search({ where: { id }, limit: 1 });
205
+ const [row] = set.length === 0
206
+ ? []
207
+ : (await set.load(['verified', 'company'])).read(['verified', 'company']);
208
+ if (row === undefined)
209
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no inbound call ${id}.`);
210
+ if (row['verified'] !== true)
211
+ throw new HttpProblem(409, 'not-verified', 'Not verified', 'A refused call is not handled.');
212
+ await env.asSuperuser({ reason: 'inbound webhook: replay' }, async (su) => {
213
+ await ref(su, 'webhook.inbound').browse([id]).write({ state: 'received', error: null });
214
+ await su.enqueue(INBOUND_JOB, { call: id, company: row['company'] }, { attempts: 6 });
215
+ });
216
+ }, { write: true });
217
+ return reply.status(202).send({ call: id });
218
+ });
219
+ }
220
+ /** The workers: events to deliveries (change events and the modules' events), and the delivery jobs. */
221
+ export function webhookWorkers(integrations) {
222
+ const { shutro, registry } = integrations;
223
+ const changes = shutro.subscribe('integrations.webhooks.changes', CHANGE_TOPIC, async (event, env) => {
224
+ const payload = (event.payload ?? {});
225
+ const verb = CHANGE_EVENTS[String(payload['action'])];
226
+ const model = String(payload['model'] ?? '');
227
+ const ids = Array.isArray(payload['ids']) ? payload['ids'] : [];
228
+ if (verb === undefined || model === '' || ids.length === 0)
229
+ return;
230
+ await fanOut(integrations, {
231
+ event: `${model}.${verb}`,
232
+ model,
233
+ ids,
234
+ company: env.companyId, // the handler runs as whoever emitted the event, in their company
235
+ });
236
+ }, { pollMs: 2000 });
237
+ const declared = registry.loaded.modules.flatMap((m) => (m.events ?? []).map((e) => shutro.subscribe(`integrations.webhooks.${e.topic}`, e.topic, async (event, env) => {
238
+ const payload = (event.payload ?? {});
239
+ const id = Number(payload['id']);
240
+ if (!Number.isInteger(id))
241
+ return;
242
+ const { id: _id, ...extra } = payload;
243
+ await fanOut(integrations, {
244
+ event: e.topic,
245
+ model: e.model,
246
+ ids: [id],
247
+ company: env.companyId, // the handler runs as whoever emitted the event, in their company
248
+ extra,
249
+ });
250
+ }, { pollMs: 2000 })));
251
+ const jobs = shutro.work({
252
+ [DELIVER_JOB]: async (payload, _env, job) => {
253
+ const p = (payload ?? {});
254
+ await deliver(integrations, Number(p.delivery), Number(p.company), {
255
+ number: job.attempt,
256
+ last: job.attempt >= job.maxAttempts,
257
+ });
258
+ },
259
+ [INBOUND_JOB]: async (payload, _env, job) => {
260
+ const p = (payload ?? {});
261
+ await handleInbound(integrations, Number(p.call), Number(p.company), job.attempt >= job.maxAttempts);
262
+ },
263
+ }, { pollMs: 2000 });
264
+ return [changes, ...declared, jobs];
265
+ }
266
+ /** The Integrations app with explicit options (tests, installations wiring their own). */
267
+ export function integrationsModule(options = {}) {
268
+ return defineModule({
269
+ shutro: integrationsShutro,
270
+ kosh: '^0.1.0',
271
+ depends: [base],
272
+ app: {
273
+ code: 'integrations',
274
+ label: 'Integrations',
275
+ icon: 'plug',
276
+ sequence: 900,
277
+ category: 'Administration',
278
+ summary: 'Webhooks: tell other systems what happens here, and hear from them',
279
+ },
280
+ accessLevels: {
281
+ manager: { label: 'Integrations manager', groups: ['integrations.manager'] },
282
+ },
283
+ // Webhooks may want any served model's changes: every one emits Shutro change events.
284
+ changeEvents: '*',
285
+ actions: {
286
+ 'integrations.action_webhooks': action('webhook.subscription', {
287
+ label: 'Webhooks',
288
+ views: ['list', 'form'],
289
+ }),
290
+ 'integrations.action_deliveries': action('webhook.delivery', {
291
+ label: 'Deliveries',
292
+ views: ['list', 'form'],
293
+ }),
294
+ 'integrations.action_endpoints': action('webhook.endpoint', {
295
+ label: 'Inbound webhooks',
296
+ views: ['list', 'form'],
297
+ }),
298
+ 'integrations.action_inbound': action('webhook.inbound', {
299
+ label: 'Inbound calls',
300
+ views: ['list', 'form'],
301
+ }),
302
+ },
303
+ menus: [
304
+ { id: 'integrations.root', label: 'Integrations', app: 'integrations' },
305
+ {
306
+ id: 'integrations.webhooks',
307
+ parent: 'integrations.root',
308
+ label: 'Webhooks',
309
+ action: 'integrations.action_webhooks',
310
+ sequence: 1,
311
+ },
312
+ {
313
+ id: 'integrations.deliveries',
314
+ parent: 'integrations.root',
315
+ label: 'Deliveries',
316
+ action: 'integrations.action_deliveries',
317
+ sequence: 2,
318
+ },
319
+ {
320
+ id: 'integrations.endpoints',
321
+ parent: 'integrations.root',
322
+ label: 'Inbound webhooks',
323
+ action: 'integrations.action_endpoints',
324
+ sequence: 3,
325
+ },
326
+ {
327
+ id: 'integrations.inbound',
328
+ parent: 'integrations.root',
329
+ label: 'Inbound calls',
330
+ action: 'integrations.action_inbound',
331
+ sequence: 4,
332
+ },
333
+ ],
334
+ views: {
335
+ 'webhook.subscription.list': list('webhook.subscription', {
336
+ columns: ['name', 'url', 'events', 'active', 'failures', 'lastDeliveryAt'],
337
+ }),
338
+ 'webhook.subscription.form': form('webhook.subscription', (k) => ({
339
+ sheet: [
340
+ k.group('main', ['name', 'url', 'active', 'runAs']),
341
+ k.group('events_group', ['events']),
342
+ k.group('state', ['failures', 'lastDeliveryAt', 'secret']),
343
+ k.component('webhook_tools', 'integrations.WebhookTools'),
344
+ ],
345
+ })),
346
+ 'webhook.delivery.list': list('webhook.delivery', {
347
+ columns: ['subscription', 'event', 'state', 'attempts', 'responseStatus', 'deliveredAt'],
348
+ }),
349
+ 'webhook.delivery.form': form('webhook.delivery', (k) => ({
350
+ sheet: [
351
+ k.group('main', ['subscription', 'event', 'state', 'replayOf']),
352
+ k.group('outcome', ['attempts', 'responseStatus', 'durationMs', 'deliveredAt', 'error']),
353
+ k.group('content', ['payload', 'responseBody']),
354
+ k.component('delivery_tools', 'integrations.DeliveryTools'),
355
+ ],
356
+ readonlyWhen: { id: { ne: 0 } },
357
+ })),
358
+ 'webhook.endpoint.list': list('webhook.endpoint', { columns: ['name', 'hook', 'active'] }),
359
+ 'webhook.endpoint.form': form('webhook.endpoint', (k) => ({
360
+ sheet: [
361
+ k.group('main', ['name', 'hook', 'active']),
362
+ k.group('access', ['token', 'secret']),
363
+ k.component('endpoint_tools', 'integrations.EndpointTools'),
364
+ ],
365
+ })),
366
+ 'webhook.inbound.list': list('webhook.inbound', {
367
+ columns: ['endpoint', 'eventId', 'state', 'attempts', 'handledAt'],
368
+ }),
369
+ 'webhook.inbound.form': form('webhook.inbound', (k) => ({
370
+ sheet: [
371
+ k.group('main', ['endpoint', 'eventId', 'verified', 'state']),
372
+ k.group('outcome', ['attempts', 'handledAt', 'result', 'error']),
373
+ k.group('content', ['headers', 'body']),
374
+ k.component('inbound_tools', 'integrations.InboundTools'),
375
+ ],
376
+ readonlyWhen: { id: { ne: 0 } },
377
+ })),
378
+ },
379
+ routes: (context) => {
380
+ const integrations = {
381
+ shutro: context.shutro,
382
+ registry: context.registry,
383
+ auth: context.auth,
384
+ ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
385
+ };
386
+ registerWebhookRoutes(context, integrations);
387
+ registerInboundRoutes(context, integrations);
388
+ if (!(options.workers ?? process.env['KOSH_SCHEDULES'] !== 'off'))
389
+ return;
390
+ const workers = webhookWorkers(integrations);
391
+ for (const w of workers)
392
+ w.start();
393
+ context.app.addHook('onClose', async () => {
394
+ await Promise.all(workers.map((w) => w.stop()));
395
+ });
396
+ },
397
+ });
398
+ }
399
+ /** The Integrations app (providers and workers from the environment). */
400
+ export const integrations = integrationsModule();
@@ -0,0 +1,6 @@
1
+ import { orm } from '../server/orm.js';
2
+ /** A new signing secret. */
3
+ export declare const newSecret: () => string;
4
+ /** A new endpoint token (the URL's last part: who knows it may call; the signature decides what is genuine). */
5
+ export declare const newToken: () => string;
6
+ export declare const integrationsShutro: ReturnType<typeof orm.defineModule>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The integrations platform's models (RFC 0017 §5.3): webhook subscriptions (a URL, the events it wants, the secret
3
+ * deliveries are signed with, the user whose read rights shape the payloads) and deliveries (each event sent to a
4
+ * subscription: the payload, every attempt's outcome, replays). Managed by `integrations.manager`.
5
+ */
6
+ import { randomBytes } from 'node:crypto';
7
+ import { baseShutro } from '../server/index.js';
8
+ import { orm } from '../server/orm.js';
9
+ const { defineGroup, defineModel, defineModule, f } = orm;
10
+ const ALL = ['read', 'write', 'create', 'unlink', 'export', 'import'];
11
+ /** A new signing secret. */
12
+ export const newSecret = () => `whsec_${randomBytes(24).toString('base64url')}`;
13
+ /** Where events are sent. */
14
+ const Subscription = defineModel('webhook.subscription', {
15
+ label: 'Webhook',
16
+ recName: 'name',
17
+ companyScoped: { required: true },
18
+ order: ['name asc', 'id asc'],
19
+ tracking: ['url', 'events', 'active', 'runAs'],
20
+ access: { 'integrations.manager': ALL },
21
+ fields: {
22
+ name: f.char({ required: true }),
23
+ /** https (http only for localhost), on the webhook host allow-list. */
24
+ url: f.char({ required: true, label: 'URL' }),
25
+ /** Space-separated: `res.partner.created`, `sale.order.updated`, `account.move.posted`… */
26
+ events: f.text({ required: true }),
27
+ active: f.boolean({ default: true }),
28
+ /** Deliveries carry `X-Shutro-Signature: t=<time>,v1=<HMAC-SHA256 of "<time>.<body>">`. */
29
+ secret: f.char({ required: true, default: newSecret, label: 'Signing secret' }),
30
+ /** Whose read rights decide what payloads contain (default: who made the webhook). */
31
+ runAs: f.many2one('res.users', { label: 'Acts as' }),
32
+ /** Deliveries that failed in a row (reset by a success). */
33
+ failures: f.integer({ required: true, default: 0, label: 'Failures in a row' }),
34
+ lastDeliveryAt: f.datetime({ label: 'Last delivery' }),
35
+ deliveries: f.one2many('webhook.delivery', 'subscription'),
36
+ },
37
+ });
38
+ /** One event sent to one subscription, with its attempts. */
39
+ const Delivery = defineModel('webhook.delivery', {
40
+ label: 'Webhook delivery',
41
+ companyScoped: { required: true },
42
+ order: ['id desc'],
43
+ access: { 'integrations.manager': ['read', 'export'] },
44
+ fields: {
45
+ subscription: f.many2one('webhook.subscription', { required: true, ondelete: 'cascade' }),
46
+ event: f.char({ required: true }),
47
+ /** The JSON body sent (`{ id, event, occurredAt, data }`). */
48
+ payload: f.json({ required: true }),
49
+ state: f.selection({ pending: 'Pending', done: 'Delivered', failed: 'Failed' }, { required: true, default: 'pending' }),
50
+ attempts: f.integer({ required: true, default: 0 }),
51
+ responseStatus: f.integer({ label: 'Response status' }),
52
+ /** The start of the response body (2 KB). */
53
+ responseBody: f.text({ label: 'Response' }),
54
+ durationMs: f.integer({ label: 'Duration (ms)' }),
55
+ error: f.text(),
56
+ deliveredAt: f.datetime({ label: 'Delivered at' }),
57
+ /** A replay: the delivery it sends again. */
58
+ replayOf: f.many2one('webhook.delivery', { label: 'Replay of' }),
59
+ },
60
+ });
61
+ /** A new endpoint token (the URL's last part: who knows it may call; the signature decides what is genuine). */
62
+ export const newToken = () => randomBytes(18).toString('base64url');
63
+ /** A company's inbound webhook (RFC 0017 §5.4): a module's endpoint, its URL token and the secret calls are checked with. */
64
+ const Endpoint = defineModel('webhook.endpoint', {
65
+ label: 'Inbound webhook',
66
+ recName: 'name',
67
+ companyScoped: { required: true },
68
+ order: ['name asc', 'id asc'],
69
+ tracking: ['active'],
70
+ access: { 'integrations.manager': ALL },
71
+ fields: {
72
+ name: f.char({ required: true }),
73
+ /** `<module>.<key>` of a declared inbound webhook. */
74
+ hook: f.char({ required: true, label: 'Endpoint' }),
75
+ token: f.char({ required: true, index: 'unique', default: newToken }),
76
+ /** What the module's check uses: the HMAC key, or the provider's signing secret. */
77
+ secret: f.char({ required: true, default: newSecret, label: 'Secret' }),
78
+ active: f.boolean({ default: true }),
79
+ calls: f.one2many('webhook.inbound', 'endpoint'),
80
+ },
81
+ });
82
+ /** A call an inbound webhook received: verified or not, handled once per event id, with the outcome. */
83
+ const Inbound = defineModel('webhook.inbound', {
84
+ label: 'Inbound call',
85
+ companyScoped: { required: true },
86
+ order: ['id desc'],
87
+ access: { 'integrations.manager': ['read', 'export'] },
88
+ fields: {
89
+ endpoint: f.many2one('webhook.endpoint', { required: true, ondelete: 'cascade' }),
90
+ /** The provider's event id (null when the call was refused). */
91
+ eventId: f.char({ label: 'Event id' }),
92
+ headers: f.json({ required: true }),
93
+ body: f.text({ required: true }),
94
+ verified: f.boolean({ required: true, default: false }),
95
+ state: f.selection({
96
+ refused: 'Refused (not verified)',
97
+ received: 'Received',
98
+ done: 'Handled',
99
+ failed: 'Failed',
100
+ }, { required: true, default: 'received' }),
101
+ attempts: f.integer({ required: true, default: 0 }),
102
+ result: f.text(),
103
+ error: f.text(),
104
+ handledAt: f.datetime({ label: 'Handled at' }),
105
+ },
106
+ });
107
+ export const integrationsShutro = defineModule({
108
+ name: 'integrations',
109
+ version: '0.1.0',
110
+ depends: [baseShutro],
111
+ groups: [
112
+ defineGroup({
113
+ code: 'integrations.manager',
114
+ label: 'Integrations manager (webhooks)',
115
+ implies: ['base.user'],
116
+ }),
117
+ ],
118
+ models: [Subscription, Delivery, Endpoint, Inbound],
119
+ });
@@ -0,0 +1,4 @@
1
+ /** On a delivery's form (RFC 0017 §5.3): send it again (the same event, for receivers that deduplicate by its id). */
2
+ import { type NodeComponentProps } from '../../ui/index.js';
3
+ import type { ReactElement } from 'react';
4
+ export declare function DeliveryTools({ api, record }: NodeComponentProps): ReactElement | null;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** On a delivery's form (RFC 0017 §5.3): send it again (the same event, for receivers that deduplicate by its id). */
3
+ import { Alert, ApiError, Button } from '../../ui/index.js';
4
+ import { useMutation } from '@tanstack/react-query';
5
+ import { useTranslation } from 'react-i18next';
6
+ export function DeliveryTools({ api, record }) {
7
+ const { t } = useTranslation('integrations');
8
+ const id = record?.['id'];
9
+ const replay = useMutation({
10
+ mutationFn: () => api.call('POST', `/api/v1/webhooks/deliveries/${id}/replay`),
11
+ });
12
+ if (typeof id !== 'number')
13
+ return null;
14
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("div", { children: _jsx(Button, { variant: "secondary", busy: replay.isPending, onClick: () => replay.mutate(), children: t('tools.replay') }) }), replay.isSuccess ? _jsx(Alert, { tone: "info", children: t('tools.replayed') }) : null, replay.error === null ? null : (_jsx(Alert, { tone: "danger", children: replay.error instanceof ApiError ? replay.error.message : String(replay.error) }))] }));
15
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * On an inbound webhook's form (RFC 0017 §5.4): the URL to give the provider, a new secret, and the endpoints the
3
+ * installed modules offer. The server checks every call; the panel shows what it answers.
4
+ */
5
+ import { type NodeComponentProps } from '../../ui/index.js';
6
+ import type { ReactElement } from 'react';
7
+ export declare function EndpointTools({ api, record, reload }: NodeComponentProps): ReactElement;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * On an inbound webhook's form (RFC 0017 §5.4): the URL to give the provider, a new secret, and the endpoints the
4
+ * installed modules offer. The server checks every call; the panel shows what it answers.
5
+ */
6
+ import { Alert, ApiError, Button } from '../../ui/index.js';
7
+ import { useMutation, useQuery } from '@tanstack/react-query';
8
+ import { useTranslation } from 'react-i18next';
9
+ export function EndpointTools({ api, record, reload }) {
10
+ const { t } = useTranslation('integrations');
11
+ const id = record?.['id'];
12
+ const token = record?.['token'];
13
+ const hooks = useQuery({
14
+ queryKey: ['integrations', 'hooks'],
15
+ queryFn: () => api.call('GET', '/api/v1/webhooks/hooks'),
16
+ });
17
+ const rotate = useMutation({
18
+ mutationFn: () => api.call('POST', `/api/v1/webhooks/endpoints/${id}/rotate-secret`),
19
+ onSuccess: () => reload(),
20
+ });
21
+ const url = typeof token === 'string' && token !== ''
22
+ ? `${window.location.origin}/api/v1/hooks/${token}`
23
+ : null;
24
+ return (_jsxs("section", { className: "grid gap-3 rounded-lg border p-4", "aria-label": t('endpoint.title'), children: [url === null ? null : (_jsxs("div", { className: "grid gap-1", children: [_jsx("span", { className: "text-sm font-medium", children: t('endpoint.url') }), _jsx("code", { className: "break-all rounded bg-muted px-2 py-1 font-mono text-xs", "data-testid": "endpoint-url", children: url }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('endpoint.urlHint') })] })), typeof id !== 'number' ? null : (_jsx("div", { children: _jsx(Button, { variant: "secondary", busy: rotate.isPending, onClick: () => rotate.mutate(), children: t('endpoint.rotate') }) })), rotate.error === null ? null : (_jsx(Alert, { tone: "danger", children: rotate.error instanceof ApiError ? rotate.error.message : String(rotate.error) })), _jsxs("details", { open: typeof id !== 'number', children: [_jsx("summary", { className: "cursor-pointer text-sm", children: t('endpoint.hooks') }), _jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: t('endpoint.hooksHint') }), _jsx("ul", { className: "mt-2 grid gap-1 text-sm", children: (hooks.data?.hooks ?? []).map((h) => (_jsxs("li", { children: [_jsx("code", { className: "font-mono text-xs", children: h.hook }), ' ', _jsx("span", { className: "text-muted-foreground", children: h.label })] }, h.hook))) }), hooks.data !== undefined && hooks.data.hooks.length === 0 ? (_jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: t('endpoint.none') })) : null] })] }));
25
+ }
@@ -0,0 +1,4 @@
1
+ /** On an inbound call's form (RFC 0017 §5.4): handle a verified call again (after fixing what made it fail). */
2
+ import { type NodeComponentProps } from '../../ui/index.js';
3
+ import type { ReactElement } from 'react';
4
+ export declare function InboundTools({ api, record }: NodeComponentProps): ReactElement | null;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** On an inbound call's form (RFC 0017 §5.4): handle a verified call again (after fixing what made it fail). */
3
+ import { Alert, ApiError, Button } from '../../ui/index.js';
4
+ import { useMutation } from '@tanstack/react-query';
5
+ import { useTranslation } from 'react-i18next';
6
+ export function InboundTools({ api, record }) {
7
+ const { t } = useTranslation('integrations');
8
+ const id = record?.['id'];
9
+ const replay = useMutation({
10
+ mutationFn: () => api.call('POST', `/api/v1/webhooks/inbound/${id}/replay`),
11
+ });
12
+ if (typeof id !== 'number' || record?.['verified'] !== true)
13
+ return null;
14
+ return (_jsxs("div", { className: "grid gap-2", children: [_jsx("div", { children: _jsx(Button, { variant: "secondary", busy: replay.isPending, onClick: () => replay.mutate(), children: t('inbound.replay') }) }), replay.isSuccess ? _jsx(Alert, { tone: "info", children: t('inbound.replayed') }) : null, replay.error === null ? null : (_jsx(Alert, { tone: "danger", children: replay.error instanceof ApiError ? replay.error.message : String(replay.error) }))] }));
15
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * On a webhook's form (RFC 0017 §5.3): send a test event, rotate the signing secret, and the events this
3
+ * installation offers. The server checks and sends; the panel shows what it answers.
4
+ */
5
+ import { type NodeComponentProps } from '../../ui/index.js';
6
+ import type { ReactElement } from 'react';
7
+ export declare function WebhookTools({ api, record, reload }: NodeComponentProps): ReactElement | null;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * On a webhook's form (RFC 0017 §5.3): send a test event, rotate the signing secret, and the events this
4
+ * installation offers. The server checks and sends; the panel shows what it answers.
5
+ */
6
+ import { Alert, ApiError, Button } from '../../ui/index.js';
7
+ import { useMutation, useQuery } from '@tanstack/react-query';
8
+ import { useTranslation } from 'react-i18next';
9
+ export function WebhookTools({ api, record, reload }) {
10
+ const { t } = useTranslation('integrations');
11
+ const id = record?.['id'];
12
+ const events = useQuery({
13
+ queryKey: ['integrations', 'events'],
14
+ queryFn: () => api.call('GET', '/api/v1/webhooks/events'),
15
+ });
16
+ const ping = useMutation({
17
+ mutationFn: () => api.call('POST', `/api/v1/webhooks/subscriptions/${id}/ping`),
18
+ });
19
+ const rotate = useMutation({
20
+ mutationFn: () => api.call('POST', `/api/v1/webhooks/subscriptions/${id}/rotate-secret`),
21
+ onSuccess: () => reload(),
22
+ });
23
+ const error = ping.error ?? rotate.error;
24
+ return (_jsxs("section", { className: "grid gap-3 rounded-lg border p-4", "aria-label": t('tools.title'), children: [typeof id !== 'number' ? null : (_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { variant: "secondary", busy: ping.isPending, onClick: () => ping.mutate(), children: t('tools.ping') }), _jsx(Button, { variant: "secondary", busy: rotate.isPending, onClick: () => rotate.mutate(), children: t('tools.rotate') })] })), ping.isSuccess ? _jsx(Alert, { tone: "info", children: t('tools.pinged') }) : null, error === null ? null : (_jsx(Alert, { tone: "danger", children: error instanceof ApiError ? error.message : String(error) })), _jsxs("details", { children: [_jsx("summary", { className: "cursor-pointer text-sm", children: t('tools.events') }), _jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: t('tools.eventsHint') }), _jsx("ul", { className: "mt-2 grid max-h-64 gap-1 overflow-auto text-sm sm:grid-cols-2", children: (events.data?.events ?? []).map((e) => (_jsxs("li", { children: [_jsx("code", { className: "font-mono text-xs", children: e.event }), ' ', _jsx("span", { className: "text-muted-foreground", children: e.label })] }, e.event))) })] })] }));
25
+ }