shutro-erp 0.2.1 → 0.3.1

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 (273) hide show
  1. package/README.md +74 -67
  2. package/dist/account_portal/.kosh/bindings.d.ts +22 -0
  3. package/dist/account_portal/.kosh/bindings.js +15 -0
  4. package/dist/account_portal/.kosh/schema.d.ts +1380 -0
  5. package/dist/account_portal/index.d.ts +3 -0
  6. package/dist/account_portal/index.js +73 -0
  7. package/dist/account_portal/module.d.ts +5983 -0
  8. package/dist/account_portal/module.js +18 -0
  9. package/dist/accounting/.kosh/bindings.js +1 -1
  10. package/dist/accounting/.kosh/schema.d.ts +172 -9
  11. package/dist/accounting/chart.d.ts +40 -8
  12. package/dist/accounting/chart.js +124 -30
  13. package/dist/accounting/closing.d.ts +6 -1
  14. package/dist/accounting/closing.js +5 -1
  15. package/dist/accounting/demo.js +6 -5
  16. package/dist/accounting/index.d.ts +3 -3
  17. package/dist/accounting/index.js +200 -23
  18. package/dist/accounting/module.d.ts +3326 -1086
  19. package/dist/accounting/module.js +408 -66
  20. package/dist/accounting/payments.d.ts +25 -0
  21. package/dist/accounting/payments.js +96 -4
  22. package/dist/accounting/web/RegisterPayment.d.ts +3 -2
  23. package/dist/accounting/web/RegisterPayment.js +39 -6
  24. package/dist/accounting/web/SettingsPage.js +5 -1
  25. package/dist/accounting/web/messages.js +10 -0
  26. package/dist/assistant/routes.js +1 -1
  27. package/dist/automation/engine.js +1 -1
  28. package/dist/automation/web/messages.js +2 -2
  29. package/dist/cli/apps.d.ts +1 -1
  30. package/dist/cli/apps.js +14 -14
  31. package/dist/cli/bin.js +3 -0
  32. package/dist/cli/index.js +11 -11
  33. package/dist/cli/init.d.ts +1 -1
  34. package/dist/cli/init.js +224 -78
  35. package/dist/cli/launch.d.ts +16 -0
  36. package/dist/cli/launch.js +138 -0
  37. package/dist/cli/project.d.ts +7 -7
  38. package/dist/cli/project.js +25 -25
  39. package/dist/cli/prompt.d.ts +35 -0
  40. package/dist/cli/prompt.js +48 -2
  41. package/dist/cli/templates.d.ts +14 -6
  42. package/dist/cli/templates.js +61 -34
  43. package/dist/cli/tui.d.ts +7 -0
  44. package/dist/cli/tui.js +254 -0
  45. package/dist/cli/ui.d.ts +21 -8
  46. package/dist/cli/ui.js +65 -19
  47. package/dist/contacts/.kosh/bindings.js +1 -1
  48. package/dist/contacts/.kosh/schema.d.ts +33 -1
  49. package/dist/contacts/index.js +24 -1
  50. package/dist/contacts/module.d.ts +42 -0
  51. package/dist/contacts/module.js +3 -1
  52. package/dist/import/convert.d.ts +55 -0
  53. package/dist/import/convert.js +224 -0
  54. package/dist/import/csv.d.ts +28 -0
  55. package/dist/import/csv.js +115 -0
  56. package/dist/import/index.d.ts +33 -0
  57. package/dist/import/index.js +60 -0
  58. package/dist/import/mapping.d.ts +15 -0
  59. package/dist/import/mapping.js +33 -0
  60. package/dist/import/match.d.ts +24 -0
  61. package/dist/import/match.js +60 -0
  62. package/dist/import/models.d.ts +2 -0
  63. package/dist/import/models.js +102 -0
  64. package/dist/import/routes.d.ts +13 -0
  65. package/dist/import/routes.js +392 -0
  66. package/dist/import/run.d.ts +131 -0
  67. package/dist/import/run.js +568 -0
  68. package/dist/import/service.d.ts +153 -0
  69. package/dist/import/service.js +367 -0
  70. package/dist/import/web/ImportButton.d.ts +7 -0
  71. package/dist/import/web/ImportButton.js +31 -0
  72. package/dist/import/web/ImportWizard.d.ts +75 -0
  73. package/dist/import/web/ImportWizard.js +217 -0
  74. package/dist/import/web/index.d.ts +13 -0
  75. package/dist/import/web/index.js +18 -0
  76. package/dist/import/web/messages.d.ts +2 -0
  77. package/dist/import/web/messages.js +139 -0
  78. package/dist/import/worker.d.ts +3 -0
  79. package/dist/import/worker.js +52 -0
  80. package/dist/import/xlsx.d.ts +20 -0
  81. package/dist/import/xlsx.js +192 -0
  82. package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
  83. package/dist/l10n_bd/.kosh/bindings.js +15 -0
  84. package/dist/l10n_bd/.kosh/schema.d.ts +1311 -0
  85. package/dist/l10n_bd/documents.d.ts +8 -0
  86. package/dist/l10n_bd/documents.js +183 -0
  87. package/dist/l10n_bd/index.d.ts +8 -0
  88. package/dist/l10n_bd/index.js +156 -0
  89. package/dist/l10n_bd/module.d.ts +5705 -0
  90. package/dist/l10n_bd/module.js +163 -0
  91. package/dist/l10n_bd/mushak.d.ts +67 -0
  92. package/dist/l10n_bd/mushak.js +139 -0
  93. package/dist/l10n_bd/routes.d.ts +3 -0
  94. package/dist/l10n_bd/routes.js +205 -0
  95. package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
  96. package/dist/l10n_bd/web/MushakPage.js +53 -0
  97. package/dist/l10n_bd/web/index.d.ts +13 -0
  98. package/dist/l10n_bd/web/index.js +18 -0
  99. package/dist/l10n_bd/web/messages.d.ts +2 -0
  100. package/dist/l10n_bd/web/messages.js +101 -0
  101. package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
  102. package/dist/l10n_in/.kosh/bindings.js +15 -0
  103. package/dist/l10n_in/.kosh/schema.d.ts +1373 -0
  104. package/dist/l10n_in/documents.d.ts +8 -0
  105. package/dist/l10n_in/documents.js +114 -0
  106. package/dist/l10n_in/einvoice.d.ts +253 -0
  107. package/dist/l10n_in/einvoice.js +485 -0
  108. package/dist/l10n_in/gstr.d.ts +109 -0
  109. package/dist/l10n_in/gstr.js +430 -0
  110. package/dist/l10n_in/index.d.ts +20 -0
  111. package/dist/l10n_in/index.js +232 -0
  112. package/dist/l10n_in/module.d.ts +7823 -0
  113. package/dist/l10n_in/module.js +427 -0
  114. package/dist/l10n_in/routes-einvoice.d.ts +15 -0
  115. package/dist/l10n_in/routes-einvoice.js +124 -0
  116. package/dist/l10n_in/routes.d.ts +17 -0
  117. package/dist/l10n_in/routes.js +282 -0
  118. package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
  119. package/dist/l10n_in/web/EInvoicePanel.js +63 -0
  120. package/dist/l10n_in/web/GstPage.d.ts +7 -0
  121. package/dist/l10n_in/web/GstPage.js +57 -0
  122. package/dist/l10n_in/web/GstSettings.d.ts +10 -0
  123. package/dist/l10n_in/web/GstSettings.js +36 -0
  124. package/dist/l10n_in/web/index.d.ts +15 -0
  125. package/dist/l10n_in/web/index.js +22 -0
  126. package/dist/l10n_in/web/messages.d.ts +2 -0
  127. package/dist/l10n_in/web/messages.js +166 -0
  128. package/dist/mail/index.js +2 -2
  129. package/dist/modules-sdk/demo.d.ts +2 -0
  130. package/dist/modules-sdk/demo.js +7 -3
  131. package/dist/modules-sdk/index.d.ts +2 -2
  132. package/dist/modules-sdk/index.js +1 -1
  133. package/dist/modules-sdk/loader.js +2 -2
  134. package/dist/modules-sdk/manifest.d.ts +132 -0
  135. package/dist/modules-sdk/manifest.js +22 -0
  136. package/dist/notify/inbox.js +21 -0
  137. package/dist/notify/messages.js +7 -3
  138. package/dist/notify/models.d.ts +1 -1
  139. package/dist/notify/models.js +2 -0
  140. package/dist/notify/routes.js +1 -1
  141. package/dist/notify/web/messages.js +5 -1
  142. package/dist/portal/.kosh/bindings.d.ts +22 -0
  143. package/dist/portal/.kosh/bindings.js +15 -0
  144. package/dist/portal/.kosh/schema.d.ts +376 -0
  145. package/dist/portal/announce.d.ts +16 -0
  146. package/dist/portal/announce.js +117 -0
  147. package/dist/portal/documents.d.ts +17 -0
  148. package/dist/portal/documents.js +61 -0
  149. package/dist/portal/index.d.ts +20 -0
  150. package/dist/portal/index.js +65 -0
  151. package/dist/portal/module.d.ts +1405 -0
  152. package/dist/portal/module.js +84 -0
  153. package/dist/portal/routes-documents.d.ts +2 -0
  154. package/dist/portal/routes-documents.js +391 -0
  155. package/dist/portal/routes.d.ts +13 -0
  156. package/dist/portal/routes.js +313 -0
  157. package/dist/portal/rules.d.ts +11 -0
  158. package/dist/portal/rules.js +25 -0
  159. package/dist/portal/web/PortalAccess.d.ts +8 -0
  160. package/dist/portal/web/PortalAccess.js +66 -0
  161. package/dist/portal/web/index.d.ts +12 -0
  162. package/dist/portal/web/index.js +17 -0
  163. package/dist/portal/web/messages.d.ts +2 -0
  164. package/dist/portal/web/messages.js +33 -0
  165. package/dist/product/.kosh/bindings.js +1 -1
  166. package/dist/product/.kosh/schema.d.ts +42 -3
  167. package/dist/product/demo.js +6 -5
  168. package/dist/product/index.d.ts +1 -0
  169. package/dist/product/index.js +17 -4
  170. package/dist/product/module.d.ts +346 -14
  171. package/dist/product/module.js +48 -18
  172. package/dist/product/taxes.d.ts +59 -0
  173. package/dist/product/taxes.js +61 -0
  174. package/dist/project/.kosh/bindings.js +1 -1
  175. package/dist/project/.kosh/schema.d.ts +33 -1
  176. package/dist/project/module.d.ts +20 -0
  177. package/dist/project_portal/.kosh/bindings.d.ts +22 -0
  178. package/dist/project_portal/.kosh/bindings.js +15 -0
  179. package/dist/project_portal/.kosh/schema.d.ts +701 -0
  180. package/dist/project_portal/index.d.ts +9 -0
  181. package/dist/project_portal/index.js +42 -0
  182. package/dist/project_portal/module.d.ts +2142 -0
  183. package/dist/project_portal/module.js +22 -0
  184. package/dist/purchase/.kosh/bindings.js +1 -1
  185. package/dist/purchase/.kosh/schema.d.ts +45 -4
  186. package/dist/purchase/demo.js +3 -3
  187. package/dist/purchase/index.js +1 -1
  188. package/dist/purchase/module.d.ts +232 -8
  189. package/dist/purchase/module.js +51 -9
  190. package/dist/purchase_account/.kosh/bindings.js +1 -1
  191. package/dist/purchase_account/.kosh/schema.d.ts +174 -10
  192. package/dist/purchase_account/module.d.ts +2008 -641
  193. package/dist/purchase_account/module.js +2 -2
  194. package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
  195. package/dist/purchase_portal/.kosh/bindings.js +15 -0
  196. package/dist/purchase_portal/.kosh/schema.d.ts +852 -0
  197. package/dist/purchase_portal/index.d.ts +9 -0
  198. package/dist/purchase_portal/index.js +53 -0
  199. package/dist/purchase_portal/module.d.ts +2758 -0
  200. package/dist/purchase_portal/module.js +18 -0
  201. package/dist/reporting/documents.js +20 -3
  202. package/dist/reporting/index.d.ts +1 -0
  203. package/dist/reporting/index.js +1 -0
  204. package/dist/reporting/routes-documents.js +9 -2
  205. package/dist/reporting/words.d.ts +11 -0
  206. package/dist/reporting/words.js +229 -0
  207. package/dist/sale_account/.kosh/bindings.js +1 -1
  208. package/dist/sale_account/.kosh/schema.d.ts +174 -10
  209. package/dist/sale_account/module.d.ts +2008 -641
  210. package/dist/sale_account/module.js +3 -3
  211. package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
  212. package/dist/sale_portal/.kosh/bindings.js +15 -0
  213. package/dist/sale_portal/.kosh/schema.d.ts +766 -0
  214. package/dist/sale_portal/index.d.ts +9 -0
  215. package/dist/sale_portal/index.js +71 -0
  216. package/dist/sale_portal/module.d.ts +2718 -0
  217. package/dist/sale_portal/module.js +19 -0
  218. package/dist/sale_project/.kosh/bindings.js +1 -1
  219. package/dist/sale_project/.kosh/schema.d.ts +45 -4
  220. package/dist/sale_project/module.d.ts +390 -10
  221. package/dist/sales/.kosh/bindings.js +1 -1
  222. package/dist/sales/.kosh/schema.d.ts +45 -4
  223. package/dist/sales/demo.js +3 -3
  224. package/dist/sales/index.js +67 -1
  225. package/dist/sales/module.d.ts +232 -8
  226. package/dist/sales/module.js +51 -12
  227. package/dist/server/api/client.js +1 -1
  228. package/dist/server/api/openapi.js +17 -1
  229. package/dist/server/apps.js +5 -5
  230. package/dist/server/auth/api-keys.d.ts +13 -0
  231. package/dist/server/auth/api-keys.js +65 -0
  232. package/dist/server/auth/base.js +26 -0
  233. package/dist/server/auth/crypto.js +1 -1
  234. package/dist/server/auth/scopes.d.ts +12 -0
  235. package/dist/server/auth/scopes.js +51 -0
  236. package/dist/server/auth/service.d.ts +59 -1
  237. package/dist/server/auth/service.js +236 -6
  238. package/dist/server/demo.d.ts +1 -1
  239. package/dist/server/demo.js +1 -1
  240. package/dist/server/env-names.d.ts +2 -0
  241. package/dist/server/env-names.js +16 -0
  242. package/dist/server/host.js +5 -1
  243. package/dist/server/identity.d.ts +7 -0
  244. package/dist/server/index.d.ts +2 -1
  245. package/dist/server/index.js +2 -1
  246. package/dist/server/registry.js +1 -1
  247. package/dist/server/server.d.ts +5 -0
  248. package/dist/server/server.js +71 -6
  249. package/dist/settings/routes.js +1 -1
  250. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  251. package/dist/settings/web/ApiKeysPage.js +70 -0
  252. package/dist/settings/web/index.d.ts +1 -0
  253. package/dist/settings/web/index.js +4 -0
  254. package/dist/settings/web/messages.js +62 -0
  255. package/dist/storage/routes.js +1 -1
  256. package/dist/studio/build.js +1 -1
  257. package/dist/studio/generate.js +4 -4
  258. package/dist/studio/module.js +5 -5
  259. package/dist/studio/web/messages.js +2 -2
  260. package/dist/ui/api.d.ts +2 -0
  261. package/dist/ui/app.js +27 -4
  262. package/dist/ui/context.d.ts +3 -1
  263. package/dist/ui/context.js +2 -2
  264. package/dist/ui/i18n/messages.js +2 -2
  265. package/dist/ui/index.d.ts +1 -1
  266. package/dist/ui/index.js +1 -1
  267. package/dist/ui/lib/format.d.ts +2 -0
  268. package/dist/ui/lib/format.js +3 -1
  269. package/dist/ui/pwa/worker.js +2 -2
  270. package/dist/ui/registry.d.ts +15 -0
  271. package/dist/ui/registry.js +20 -0
  272. package/dist/ui/views/FormView.js +19 -6
  273. package/package.json +52 -4
@@ -36,6 +36,7 @@ export class AuthService {
36
36
  #shutro;
37
37
  #options;
38
38
  #cache = new Map();
39
+ #keyCache = new Map();
39
40
  #dummy = hashPassword(randomToken());
40
41
  constructor(shutro, options = {}) {
41
42
  this.#shutro = shutro;
@@ -212,14 +213,27 @@ export class AuthService {
212
213
  throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
213
214
  return session;
214
215
  }
215
- /** Authenticator for the server (RFC 0002 §6.2): the session's user, company from `X-Company`, groups for it. */
216
+ /**
217
+ * Authenticator for the server (RFC 0002 §6.2): the session's user — or the API key's (RFC 0017 §5.1), with its
218
+ * scopes —, company from `X-Company`, groups for it.
219
+ */
216
220
  async authenticate(request) {
221
+ const key = await this.apiKeyOf(request);
222
+ if (key !== null)
223
+ return this.#identity(request, key.user, {
224
+ elevated: false,
225
+ scopes: key.scopes,
226
+ apiKey: key.id,
227
+ });
217
228
  const session = await this.session(request);
218
229
  if (session === null)
219
230
  return null;
220
231
  if (session.twoFactorRequired)
221
232
  throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
222
- const info = await this.#identityOf(session.user);
233
+ return this.#identity(request, session.user, { elevated: session.secondFactor });
234
+ }
235
+ async #identity(request, user, options) {
236
+ const info = await this.#identityOf(user);
223
237
  if (info === null)
224
238
  return null;
225
239
  const asked = Number(request.headers['x-company']);
@@ -229,8 +243,8 @@ export class AuthService {
229
243
  if (company === undefined)
230
244
  throw new HttpProblem(403, 'no-company', 'No company', 'This user is not allowed in any company.', 'An administrator assigns the companies of the user.');
231
245
  // Mandatory 2FA for administrators (RFC 0003 §5.2): their administrator groups count only in sessions that
232
- // entered a second factor, while 2FA is on. The user keeps every other group.
233
- const elevated = session.secondFactor && info.twoFactorEnabled;
246
+ // entered a second factor, while 2FA is on — never through an API key. The user keeps every other group.
247
+ const elevated = options.elevated && info.twoFactorEnabled;
234
248
  const groups = info.assignments
235
249
  .filter((a) => a.company === null || a.company === company)
236
250
  .map((a) => a.group)
@@ -241,15 +255,225 @@ export class AuthService {
241
255
  (g.expiresAt === null || g.expiresAt > now))
242
256
  .map((g) => g.grant);
243
257
  return {
244
- user: session.user,
258
+ user,
245
259
  company,
246
260
  companies: info.companies,
247
261
  groups: [...new Set(groups)],
248
262
  grants: [...new Set(grants)],
249
263
  lang: info.lang,
250
264
  tz: info.tz,
265
+ ...(options.scopes === undefined ? {} : { scopes: options.scopes }),
266
+ ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
251
267
  };
252
268
  }
269
+ /**
270
+ * The request's API key (RFC 0017 §5.1): `X-Api-Key: sk_…` or `Authorization: Bearer sk_…`; null without one.
271
+ * @throws {HttpProblem} 401 for an `X-Api-Key` that is unknown, revoked or expired
272
+ */
273
+ async apiKeyOf(request) {
274
+ const header = request.headers['x-api-key'];
275
+ const explicit = typeof header === 'string' && header !== '' ? header : undefined;
276
+ const bearer = /^Bearer\s+(sk_\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
277
+ const token = explicit ?? bearer;
278
+ if (token === undefined)
279
+ return null;
280
+ const tokenHash = digest(token);
281
+ const cached = this.#keyCache.get(tokenHash);
282
+ if (cached !== undefined && Date.now() - cached.at < this.#options.cacheMs)
283
+ return cached.key;
284
+ const key = await this.#system('api key', async (env) => {
285
+ const row = one(await fetch(model(env, 'auth.api_key').search({ where: { tokenHash, revoked: false } }), [
286
+ 'user',
287
+ 'scopes',
288
+ 'expiresAt',
289
+ 'lastUsedAt',
290
+ ]));
291
+ if (row === undefined)
292
+ return null;
293
+ const now = Date.now();
294
+ if ((timeOf(row['expiresAt']) ?? 0) <= now)
295
+ return null;
296
+ // Archived users' keys stop working with them.
297
+ if ((await model(env, 'res.users').search({ where: { id: row['user'] } })).length === 0)
298
+ return null;
299
+ if ((timeOf(row['lastUsedAt']) ?? 0) + 5 * 60_000 < now)
300
+ await model(env, 'auth.api_key')
301
+ .browse([row['id']])
302
+ .write({ lastUsedAt: new Date(now), lastUsedIp: request.ip });
303
+ return {
304
+ id: row['id'],
305
+ user: row['user'],
306
+ scopes: String(row['scopes'])
307
+ .split(' ')
308
+ .filter((x) => x !== ''),
309
+ };
310
+ });
311
+ if (key === null) {
312
+ this.#keyCache.delete(tokenHash);
313
+ // A bearer token that is no key may still be a session's.
314
+ if (explicit === undefined)
315
+ return null;
316
+ throw unauthenticated('invalid-api-key', 'The API key is unknown, revoked or expired.', 'Create a new key (Settings › API keys).');
317
+ }
318
+ this.#keyCache.set(tokenHash, { at: Date.now(), key });
319
+ return key;
320
+ }
321
+ /** May `actor` manage `user`'s keys: their own, or a service account's when the actor is an administrator. */
322
+ async #mayManageKeysOf(env, session, user) {
323
+ if (user === session.user)
324
+ return true;
325
+ const [target] = await fetch(model(env, 'res.users').search({ where: { id: user } }), [
326
+ 'isService',
327
+ ]);
328
+ return (target?.['isService'] === true &&
329
+ session.secondFactor &&
330
+ (await this.#holdsAdministrator(env, session.user)));
331
+ }
332
+ /**
333
+ * A new API key for `user` (the session's own, or a service account's for administrators), with `scopes` already
334
+ * checked against the installed apps. The secret is returned once.
335
+ */
336
+ async createApiKey(session, input, ip) {
337
+ const days = input.expiresInDays ?? 365;
338
+ if (!Number.isInteger(days) || days < 1 || days > 730)
339
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'expiresInDays is 1 to 730.');
340
+ const name = input.name.trim();
341
+ if (name === '' || name.length > 100)
342
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'A key has a name of up to 100 characters.');
343
+ const user = input.user ?? session.user;
344
+ return this.#system('create api key', async (env) => {
345
+ if (!(await this.#mayManageKeysOf(env, session, user)))
346
+ throw new HttpProblem(403, 'access', 'Forbidden', 'Keys are made for yourself, or by an administrator for a service account.');
347
+ const token = `sk_${randomToken(30)
348
+ .replace(/[^A-Za-z0-9]/g, '')
349
+ .slice(0, 40)}`;
350
+ const prefix = token.slice(0, 11);
351
+ const created = await model(env, 'auth.api_key').create([
352
+ {
353
+ name,
354
+ user,
355
+ tokenHash: digest(token),
356
+ prefix,
357
+ scopes: input.scopes.join(' '),
358
+ expiresAt: new Date(Date.now() + days * 86_400_000),
359
+ issuedBy: session.user,
360
+ },
361
+ ]);
362
+ const id = created.ids[0];
363
+ await audit(env, {
364
+ kind: 'auth.api_key_created',
365
+ summary: `API key "${name}" (${prefix}…) created for user ${user}: ${input.scopes.join(' ')}.`,
366
+ actor: session.user,
367
+ company: null,
368
+ details: { key: id, user, scopes: [...input.scopes], expiresInDays: days },
369
+ ...(ip === undefined ? {} : { ip }),
370
+ });
371
+ const [info] = await this.#keys(env, [id]);
372
+ return { ...info, token };
373
+ });
374
+ }
375
+ async #keys(env, ids, users) {
376
+ const where = ids !== undefined ? { id: { in: [...ids] } } : { user: { in: [...(users ?? [])] } };
377
+ const rows = await fetch(model(env, 'auth.api_key').search({ where, order: ['id desc'] }), [
378
+ 'name',
379
+ 'user',
380
+ 'prefix',
381
+ 'scopes',
382
+ 'expiresAt',
383
+ 'lastUsedAt',
384
+ 'lastUsedIp',
385
+ 'revoked',
386
+ 'createdAt',
387
+ ]);
388
+ const userIds = [...new Set(rows.map((r) => r['user']))];
389
+ const names = new Map((userIds.length === 0
390
+ ? []
391
+ : await fetch(model(env, 'res.users').search({ where: { id: { in: userIds } } }), ['name'])).map((u) => [u['id'], String(u['name'])]));
392
+ return rows.map((r) => ({
393
+ id: r['id'],
394
+ name: String(r['name']),
395
+ user: r['user'],
396
+ userName: names.get(r['user']) ?? '',
397
+ prefix: String(r['prefix']),
398
+ scopes: String(r['scopes'])
399
+ .split(' ')
400
+ .filter((x) => x !== ''),
401
+ expiresAt: new Date(timeOf(r['expiresAt']) ?? 0).toISOString(),
402
+ lastUsedAt: timeOf(r['lastUsedAt']) === null
403
+ ? null
404
+ : new Date(timeOf(r['lastUsedAt']) ?? 0).toISOString(),
405
+ lastUsedIp: typeof r['lastUsedIp'] === 'string' ? r['lastUsedIp'] : null,
406
+ revoked: r['revoked'] === true,
407
+ }));
408
+ }
409
+ /** The session user's keys, and every service account's for administrators. */
410
+ async listApiKeys(session) {
411
+ return this.#system('list api keys', async (env) => {
412
+ const users = [session.user];
413
+ if (session.secondFactor && (await this.#holdsAdministrator(env, session.user)))
414
+ users.push(...(await model(env, 'res.users').search({ where: { isService: true } })).ids);
415
+ return this.#keys(env, undefined, users);
416
+ });
417
+ }
418
+ /** Revokes a key at once (the session's own, or a service account's for administrators). */
419
+ async revokeApiKey(session, id, ip) {
420
+ await this.#system('revoke api key', async (env) => {
421
+ const [key] = await fetch(model(env, 'auth.api_key').search({ where: { id } }), [
422
+ 'user',
423
+ 'prefix',
424
+ 'tokenHash',
425
+ ]);
426
+ if (key === undefined || !(await this.#mayManageKeysOf(env, session, key['user'])))
427
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no API key ${id} of yours.`);
428
+ await model(env, 'auth.api_key').browse([id]).write({ revoked: true });
429
+ this.#keyCache.delete(String(key['tokenHash']));
430
+ await audit(env, {
431
+ kind: 'auth.api_key_revoked',
432
+ summary: `API key ${String(key['prefix'])}… revoked.`,
433
+ actor: session.user,
434
+ company: null,
435
+ details: { key: id },
436
+ ...(ip === undefined ? {} : { ip }),
437
+ });
438
+ });
439
+ }
440
+ /**
441
+ * A service account (RFC 0017 §5.1): a user without a password who acts only through API keys, with `groups`
442
+ * (checked by the caller) in the administrator's companies. Administrators only.
443
+ */
444
+ async createServiceAccount(session, input, ip) {
445
+ return this.#system(`create service account ${input.login}`, async (env) => {
446
+ if (!(session.secondFactor && (await this.#holdsAdministrator(env, session.user))))
447
+ throw new HttpProblem(403, 'access', 'Forbidden', 'Service accounts are created by administrators.');
448
+ const administrators = new Set(env.access.administratorGroups());
449
+ if (input.groups.some((g) => administrators.has(g)))
450
+ throw new HttpProblem(422, 'invalid-groups', 'Invalid roles', 'A service account cannot be an administrator.');
451
+ const [me] = await fetch(model(env, 'res.users').search({ where: { id: session.user } }), [
452
+ 'company',
453
+ ]);
454
+ const company = typeof me?.['company'] === 'number' ? me['company'] : undefined;
455
+ const created = await model(env, 'res.users').create([
456
+ {
457
+ login: input.login,
458
+ name: input.name,
459
+ isService: true,
460
+ ...(company === undefined ? {} : { company, companies: { set: [company] } }),
461
+ },
462
+ ]);
463
+ const id = created.ids[0];
464
+ if (input.groups.length > 0)
465
+ await model(env, 'auth.user_group').create(input.groups.map((group) => ({ user: id, group })));
466
+ await audit(env, {
467
+ kind: 'auth.service_account_created',
468
+ summary: `Service account ${input.login} created with ${input.groups.join(', ') || 'no roles'}.`,
469
+ actor: session.user,
470
+ company: null,
471
+ details: { user: id, groups: [...input.groups] },
472
+ ...(ip === undefined ? {} : { ip }),
473
+ });
474
+ return { id, login: input.login, name: input.name, groups: [...input.groups] };
475
+ });
476
+ }
253
477
  /** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
254
478
  async verify(uid) {
255
479
  const info = await this.#identityOf(uid);
@@ -320,12 +544,18 @@ export class AuthService {
320
544
  const [credential] = await fetch(model(env, 'auth.credential').search({ where: { user: session.user } }), ['totpEnabled']);
321
545
  const administrator = await this.#holdsAdministrator(env, session.user);
322
546
  const twoFactorEnabled = credential?.['totpEnabled'] === true;
547
+ const companyIds = user?.['companies'] ?? [];
548
+ const groupings = {};
549
+ if (companyIds.length > 0)
550
+ for (const c of await fetch(model(env, 'res.company').search({ where: { id: { in: companyIds } } }), ['numberGrouping']))
551
+ groupings[String(c['id'])] = c['numberGrouping'] === 'lakh' ? 'lakh' : 'international';
323
552
  return {
324
553
  id: session.user,
325
554
  login: String(user?.['login']),
326
555
  name: String(user?.['name']),
327
556
  company: typeof user?.['company'] === 'number' ? user['company'] : null,
328
- companies: user?.['companies'] ?? [],
557
+ companies: companyIds,
558
+ groupings,
329
559
  twoFactorEnabled,
330
560
  administrator,
331
561
  administratorNeedsSecondFactor: administrator && !(session.secondFactor && twoFactorEnabled),
@@ -8,7 +8,7 @@ import type { KoshModule } from '../modules-sdk/index.js';
8
8
  import type { AuthService } from './auth/service.js';
9
9
  import type { Driver, Shutro } from './orm.js';
10
10
  /** The demo users' password (every demo user; printed by `kosh init --demo`). */
11
- export declare const DEMO_PASSWORD = "kosh demo 2026";
11
+ export declare const DEMO_PASSWORD = "shutro demo 2026";
12
12
  /** What one module's seeder did. */
13
13
  export interface DemoResult {
14
14
  readonly module: string;
@@ -1,6 +1,6 @@
1
1
  import { APPS_TABLE, appTableStatements } from './apps.js';
2
2
  /** The demo users' password (every demo user; printed by `kosh init --demo`). */
3
- export const DEMO_PASSWORD = 'kosh demo 2026';
3
+ export const DEMO_PASSWORD = 'shutro demo 2026';
4
4
  const model = (env, name) => env.model(name);
5
5
  /** Loads the demo data of the modules that have none yet. Returns a line per module that seeded something. */
6
6
  export async function installDemo(options) {
@@ -0,0 +1,2 @@
1
+ /** Copies `SHUTRO_ERP_*` variables to their `KOSH_*` names in `env` (the process's by default); returns `env`. */
2
+ export declare function applyEnvironmentNames(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The environment's names: installations set `SHUTRO_ERP_*` (the product's name, as in the docs and new projects);
3
+ * the code reads the older `KOSH_*` names, which keep working. Applied once where a process starts (the server, the
4
+ * CLI): each `SHUTRO_ERP_X` that is set fills `KOSH_X` unless that one is set too.
5
+ */
6
+ const PREFIX = 'SHUTRO_ERP_';
7
+ /** Copies `SHUTRO_ERP_*` variables to their `KOSH_*` names in `env` (the process's by default); returns `env`. */
8
+ export function applyEnvironmentNames(env = process.env) {
9
+ for (const [name, value] of Object.entries(env))
10
+ if (name.startsWith(PREFIX) && value !== undefined) {
11
+ const old = `KOSH_${name.slice(PREFIX.length)}`;
12
+ if (env[old] === undefined)
13
+ env[old] = value;
14
+ }
15
+ return env;
16
+ }
@@ -152,7 +152,11 @@ export async function createAppHost(options) {
152
152
  current.server.app.server.emit('upgrade', req, socket, head);
153
153
  });
154
154
  const listener = listen
155
- ? pg.createDriver({ url: options.databaseUrl, max: 1, applicationName: 'kosh apps listener' })
155
+ ? pg.createDriver({
156
+ url: options.databaseUrl,
157
+ max: 1,
158
+ applicationName: 'shutro-erp apps listener',
159
+ })
156
160
  : undefined;
157
161
  const stopListening = await listener?.listen(APPS_CHANNEL, (digest) => {
158
162
  if (digest !== current.digest)
@@ -17,6 +17,13 @@ export interface RequestIdentity {
17
17
  readonly grants?: readonly string[];
18
18
  readonly lang?: string;
19
19
  readonly tz?: string;
20
+ /**
21
+ * An API key's scopes (RFC 0017 §5.1), when the request carries one: they narrow what the user may do (`*`,
22
+ * `read:<app>`, `write:<app>`); the user's rights still apply. Absent for sessions.
23
+ */
24
+ readonly scopes?: readonly string[];
25
+ /** The API key the request carries. */
26
+ readonly apiKey?: number;
20
27
  }
21
28
  /**
22
29
  * The single explicit bypass (CLAUDE.md "superuser mode", RFC 0003 §5.2): the user holds an administrator role
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { announceChange, CHANGES_CHANNEL, parseChange, type RecordChange } from './api/changes.js';
7
7
  export { generateClient, typeOf } from './api/client.js';
8
- export { IDEMPOTENCY_TABLE, idempotencyTableStatements, type Outcome } from './api/idempotency.js';
8
+ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, type Outcome, } from './api/idempotency.js';
9
9
  export { type ApiField, type ApiModel, apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { type FoundRecord, type MenuNode, moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
@@ -20,6 +20,7 @@ export { type InstallBaseOptions, installBase } from './auth/install.js';
20
20
  export { registerAuthRoutes } from './auth/routes.js';
21
21
  export { type AuthOptions, AuthService, cookieOf, SESSION_COOKIE, type SessionInfo, timeOf, type UserInfo, } from './auth/service.js';
22
22
  export { DEMO_PASSWORD, type DemoResult, installDemo } from './demo.js';
23
+ export { applyEnvironmentNames } from './env-names.js';
23
24
  export { HttpProblem, type Problem, problemOf, ServerConfigError } from './errors.js';
24
25
  export { type AppHost, type AppHostOptions, createAppHost, startAppHost } from './host.js';
25
26
  export { type Authenticator, denyAll, isSuperuser, languageOf, type RequestIdentity, type RequestRunOptions, runOptionsOf, } from './identity.js';
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { announceChange, CHANGES_CHANNEL, parseChange } from './api/changes.js';
7
7
  export { generateClient, typeOf } from './api/client.js';
8
- export { IDEMPOTENCY_TABLE, idempotencyTableStatements } from './api/idempotency.js';
8
+ export { IDEMPOTENCY_TABLE, idempotencyTableStatements, idempotent, } from './api/idempotency.js';
9
9
  export { apiModels, defaultFields, publicApiModels, schemaName, } from './api/metadata.js';
10
10
  export { moduleVisible, Navigation } from './api/navigation.js';
11
11
  export { fieldSchema, openApiDocument, openApiOf } from './api/openapi.js';
@@ -20,6 +20,7 @@ export { installBase } from './auth/install.js';
20
20
  export { registerAuthRoutes } from './auth/routes.js';
21
21
  export { AuthService, cookieOf, SESSION_COOKIE, timeOf, } from './auth/service.js';
22
22
  export { DEMO_PASSWORD, installDemo } from './demo.js';
23
+ export { applyEnvironmentNames } from './env-names.js';
23
24
  export { HttpProblem, problemOf, ServerConfigError } from './errors.js';
24
25
  export { createAppHost, startAppHost } from './host.js';
25
26
  export { denyAll, isSuperuser, languageOf, runOptionsOf, } from './identity.js';
@@ -53,6 +53,6 @@ export function buildKoshRegistry(modules, options = {}) {
53
53
  });
54
54
  for (const name of registry.loadOrder)
55
55
  if (!loaded.byName.has(name) && name !== CHANGE_EVENTS_MODULE)
56
- throw new ServerConfigError(`The Shutro module '${name}' has no Kosh manifest.`, 'Wrap it with defineModule() from shutro-erp/modules-sdk (its access levels are required) and depend on it.');
56
+ throw new ServerConfigError(`The Shutro module '${name}' has no Shutro ERP manifest.`, 'Wrap it with defineModule() from shutro-erp/modules-sdk (its access levels are required) and depend on it.');
57
57
  return { loaded, registry };
58
58
  }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The Kosh server (RFC 0002, CLAUDE.md robustness): Fastify with Pino logs (request ids, redacted credentials),
3
+ * authentication through an {@link Authenticator}, one Shutro transaction per request (`request.env`), RFC 9457
4
+ * errors, rate limiting on Valkey, OpenTelemetry spans, health checks, and graceful shutdown.
5
+ */
1
6
  import type { KoshModule } from '../modules-sdk/index.js';
2
7
  import type { Span } from '@opentelemetry/api';
3
8
  import { type FastifyInstance, type FastifyServerFactory, type FastifyServerOptions } from 'fastify';
@@ -12,9 +12,12 @@ import { Navigation } from './api/navigation.js';
12
12
  import { openApiDocument } from './api/openapi.js';
13
13
  import { registerRestApi } from './api/rest.js';
14
14
  import { ViewRegistry } from './api/views.js';
15
+ import { registerApiKeyRoutes } from './auth/api-keys.js';
15
16
  import { registerAuthRoutes } from './auth/routes.js';
17
+ import { enforceScopes } from './auth/scopes.js';
16
18
  import { AuthService } from './auth/service.js';
17
- import { HttpProblem, problemOf } from './errors.js';
19
+ import { applyEnvironmentNames } from './env-names.js';
20
+ import { HttpProblem, problemOf, ServerConfigError } from './errors.js';
18
21
  import { denyAll, runOptionsOf } from './identity.js';
19
22
  import { orm, pg } from './orm.js';
20
23
  import { buildKoshRegistry, KOSH_VERSION } from './registry.js';
@@ -25,8 +28,25 @@ const REDACT = [
25
28
  'req.headers["x-api-key"]',
26
29
  'res.headers["set-cookie"]',
27
30
  ];
31
+ /** What a portal user reaches: the portal API, and signing in and out (RFC 0015 §5.1). */
32
+ const PORTAL_PATHS = ['/api/portal/', '/api/v1/auth/'];
33
+ /**
34
+ * The external groups the modules declare (RFC 0015 §5.1). @throws {ServerConfigError} for one implying
35
+ * `base.user`: its holders would be internal users.
36
+ */
37
+ function externalGroupsOf(registry) {
38
+ const groups = new Set(registry.loaded.modules.flatMap((m) => m.externalGroups ?? []));
39
+ for (const group of groups) {
40
+ if (!registry.registry.security.groups.has(group))
41
+ throw new ServerConfigError(`The external group '${group}' is not defined.`, 'Define it in the module that declares it.');
42
+ if (registry.registry.security.closure([group]).has('base.user'))
43
+ throw new ServerConfigError(`The external group '${group}' implies base.user: its users would be internal.`, 'Remove base.user from what it implies.');
44
+ }
45
+ return groups;
46
+ }
28
47
  /** Creates the server (not listening yet). @throws {ServerConfigError} for modules that cannot load together */
29
48
  export async function createServer(options) {
49
+ applyEnvironmentNames(); // SHUTRO_ERP_* settings, read under their KOSH_* names
30
50
  const registry = buildKoshRegistry(options.modules, {
31
51
  ...(options.security === undefined ? {} : { security: options.security }),
32
52
  });
@@ -37,6 +57,7 @@ export async function createServer(options) {
37
57
  applicationName: 'kosh',
38
58
  });
39
59
  const hasBase = registry.registry.models.has('auth.session');
60
+ const external = externalGroupsOf(registry);
40
61
  let shutro;
41
62
  const auth = hasBase ? new AuthService(() => shutro, options.auth) : undefined;
42
63
  const verify = auth !== undefined && (options.verifyIdentities ?? options.authenticate === undefined);
@@ -85,6 +106,15 @@ export async function createServer(options) {
85
106
  }
86
107
  });
87
108
  });
109
+ // The app each module route belongs to (API key scopes, RFC 0017 §5.1): set while a module registers its routes.
110
+ const routeApps = new Map();
111
+ let routeModule = null;
112
+ app.addHook('onRoute', (route) => {
113
+ if (routeModule === null)
114
+ return;
115
+ for (const method of [route.method].flat())
116
+ routeApps.set(`${method} ${route.url}`, routeModule);
117
+ });
88
118
  app.addHook('onRequest', async (request, reply) => {
89
119
  reply.header('x-request-id', request.id);
90
120
  request.span = tracer.startSpan(`HTTP ${request.method}`, {
@@ -101,6 +131,25 @@ export async function createServer(options) {
101
131
  if (config.public === true || config.auth === false)
102
132
  return;
103
133
  request.identity = await authenticate(request);
134
+ // An API key's scopes (RFC 0017 §5.1): the route's app is the module that registered it, or its model's.
135
+ const scopes = request.identity?.scopes;
136
+ if (scopes !== undefined) {
137
+ const route = request.routeOptions.url ?? '';
138
+ const model = request.params?.model;
139
+ const owner = routeApps.get(`${request.method} ${route}`) ??
140
+ (typeof model === 'string'
141
+ ? (registry.registry.models.get(model)?.module ?? null)
142
+ : null);
143
+ enforceScopes(scopes, request.method, route, owner);
144
+ }
145
+ // Customers and vendors of the portal (RFC 0015 §5.1): the portal API and signing in, nothing else.
146
+ if (request.identity !== null &&
147
+ external.size > 0 &&
148
+ request.identity.groups.length > 0 &&
149
+ request.identity.groups.every((g) => external.has(g)) &&
150
+ !PORTAL_PATHS.some((p) => request.url.startsWith(p)) &&
151
+ request.url.startsWith('/api/'))
152
+ throw new HttpProblem(403, 'internal-only', 'For the company’s users only', 'Portal users reach the portal only.');
104
153
  });
105
154
  if (options.rateLimit?.max !== 0)
106
155
  await app.register(rateLimit, {
@@ -108,7 +157,11 @@ export async function createServer(options) {
108
157
  max: options.rateLimit?.max ?? 600,
109
158
  timeWindow: options.rateLimit?.timeWindow ?? '1 minute',
110
159
  ...(valkey === undefined ? {} : { redis: valkey, nameSpace: 'kosh:ratelimit:' }),
111
- keyGenerator: (request) => request.identity === null ? `ip:${request.ip}` : `user:${request.identity.user}`,
160
+ keyGenerator: (request) => request.identity === null
161
+ ? `ip:${request.ip}`
162
+ : request.identity.apiKey !== undefined
163
+ ? `key:${request.identity.apiKey}`
164
+ : `user:${request.identity.user}`,
112
165
  allowList: (request) => request.routeOptions.config.public === true,
113
166
  errorResponseBuilder: (_request, context) => new HttpProblem(429, 'rate-limited', 'Too many requests', `Limit of ${context.max} requests per ${context.after} reached.`, `Retry after ${context.after}.`),
114
167
  });
@@ -145,8 +198,10 @@ export async function createServer(options) {
145
198
  detail: `No route ${request.method} ${request.url.split('?')[0] ?? ''}.`,
146
199
  });
147
200
  });
148
- if (auth !== undefined)
201
+ if (auth !== undefined) {
149
202
  registerAuthRoutes(app, auth, { loginLimit: options.auth?.loginLimit ?? 10 });
203
+ registerApiKeyRoutes(app, auth, registry);
204
+ }
150
205
  // The generated REST API and its OpenAPI document (RFC 0002 §6.3–6.4).
151
206
  for (const statement of idempotencyTableStatements())
152
207
  await driver.query(statement);
@@ -157,7 +212,10 @@ export async function createServer(options) {
157
212
  models,
158
213
  driver,
159
214
  openapi: () => {
160
- openapi ??= openApiDocument(models.values(), { title: 'Kosh API', version: KOSH_VERSION });
215
+ openapi ??= openApiDocument(models.values(), {
216
+ title: 'Shutro ERP API',
217
+ version: KOSH_VERSION,
218
+ });
161
219
  return openapi;
162
220
  },
163
221
  });
@@ -173,8 +231,15 @@ export async function createServer(options) {
173
231
  ...(options.apps === undefined ? {} : { apps: options.apps }),
174
232
  };
175
233
  for (const m of registry.loaded.modules)
176
- if (m.routes !== undefined)
177
- await m.routes(context);
234
+ if (m.routes !== undefined) {
235
+ routeModule = m.name;
236
+ try {
237
+ await m.routes(context);
238
+ }
239
+ finally {
240
+ routeModule = null;
241
+ }
242
+ }
178
243
  // Health checks: public, not rate limited. Liveness: the process answers. Readiness: dependencies answer.
179
244
  app.get('/health/live', { config: { public: true } }, async () => ({ status: 'ok' }));
180
245
  app.get('/health/ready', { config: { public: true } }, async (_request, reply) => {
@@ -64,7 +64,7 @@ export function registerAppRoutes(context) {
64
64
  const { app, driver } = context;
65
65
  const appsOf = () => {
66
66
  if (context.apps === undefined)
67
- throw new HttpProblem(503, 'app-manager-unavailable', 'App manager unavailable', 'This server does not run in an app host, so it cannot change the installed apps.', 'Start the server with createAppHost (the starter project does), or use npx kosh apps.');
67
+ throw new HttpProblem(503, 'app-manager-unavailable', 'App manager unavailable', 'This server does not run in an app host, so it cannot change the installed apps.', 'Start the server with createAppHost (the starter project does), or use npx shutro-erp apps.');
68
68
  return context.apps;
69
69
  };
70
70
  /** Administrators and App managers only; returns the caller's user id. */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Settings › API keys (RFC 0017 §5.1): your keys — and the service accounts' for administrators —, a new key with its
3
+ * scopes and expiry, its secret shown once, revocation. The server decides what each key may do; scopes only narrow
4
+ * the user's own rights.
5
+ */
6
+ import { type PageProps } from '../../ui/index.js';
7
+ import { type ReactElement } from 'react';
8
+ export declare function ApiKeysPage({ api }: PageProps): ReactElement;