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
@@ -5,9 +5,14 @@
5
5
  */
6
6
  import { contactsShutro } from '../contacts/index.js';
7
7
  import { messagingShutro } from '../messaging/index.js';
8
- import { productShutro } from '../product/index.js';
8
+ import { computeTaxes, productShutro, taxRule } from '../product/index.js';
9
9
  import { orm } from '../server/orm.js';
10
10
  import { check, compute, defineModel, defineModule, f, onchange, records, } from './.kosh/bindings.js';
11
+ /** A line's taxes through the engine (RFC 0016): its amount after discount, its taxes in order. */
12
+ function taxesOf(l) {
13
+ const amount = l.quantity.times(l.priceUnit).times(orm.toDecimal(100).minus(l.discount)).div(100);
14
+ return computeTaxes(amount, l.quantity, [...l.taxes].map(taxRule));
15
+ }
11
16
  const amounts = compute('sale.order', ['amountUntaxed', 'amountTax', 'amountTotal'], ['lines.subtotal', 'lines.taxAmount'], (orders) => {
12
17
  for (const o of orders) {
13
18
  // A new order has no currency until its partner is chosen: no amounts yet.
@@ -124,7 +129,10 @@ export const SaleOrderLine = defineModel('sale.order.line', {
124
129
  priceUnit: f.decimal({ scale: 2, required: true, default: '0', label: 'Unit price' }),
125
130
  /** Percent off the unit price (0–100). */
126
131
  discount: f.decimal({ scale: 2, required: true, default: '0', label: 'Discount %' }),
127
- tax: f.many2one('account.tax'),
132
+ /** The line's taxes, applied in order (RFC 0016). */
133
+ taxes: f.many2many('account.tax'),
134
+ /** Before 0.2.0, one tax per line: copied into `taxes` by the migration; dropped later. */
135
+ tax: f.many2one('account.tax', { label: 'Tax (before 0.2.0)', readonly: true }),
128
136
  /** The order's currency, so line amounts are Money in it. */
129
137
  currency: f.many2one('res.currency', {
130
138
  store: true,
@@ -136,33 +144,54 @@ export const SaleOrderLine = defineModel('sale.order.line', {
136
144
  subtotal: f.monetary({
137
145
  currency: 'currency',
138
146
  store: true,
139
- ...compute('sale.order.line', 'subtotal', ['quantity', 'priceUnit', 'discount'], (lines) => {
147
+ ...compute('sale.order.line', 'subtotal', [
148
+ 'quantity',
149
+ 'priceUnit',
150
+ 'discount',
151
+ 'taxes.rate',
152
+ 'taxes.sequence',
153
+ 'taxes.amountType',
154
+ 'taxes.fixedAmount',
155
+ 'taxes.affectsBase',
156
+ 'taxes.priceIncluded',
157
+ 'taxes.reverseCharge',
158
+ ], (lines) => {
140
159
  for (const l of lines)
141
- l.subtotal = l.quantity
142
- .times(l.priceUnit)
143
- .times(orm.toDecimal(100).minus(l.discount))
144
- .div(100);
160
+ l.subtotal = taxesOf(l).untaxed;
145
161
  }),
146
162
  }),
147
163
  taxAmount: f.monetary({
148
164
  currency: 'currency',
149
165
  store: true,
150
166
  label: 'Tax',
151
- ...compute('sale.order.line', 'taxAmount', ['subtotal', 'tax.rate'], (lines) => {
167
+ ...compute('sale.order.line', 'taxAmount', [
168
+ 'quantity',
169
+ 'priceUnit',
170
+ 'discount',
171
+ 'taxes.rate',
172
+ 'taxes.sequence',
173
+ 'taxes.amountType',
174
+ 'taxes.fixedAmount',
175
+ 'taxes.affectsBase',
176
+ 'taxes.priceIncluded',
177
+ 'taxes.reverseCharge',
178
+ ], (lines) => {
152
179
  for (const l of lines)
153
- l.taxAmount = (l.subtotal?.amount ?? orm.toDecimal(0)).times(l.tax?.rate ?? 0);
180
+ l.taxAmount = taxesOf(l).taxTotal;
154
181
  }),
155
182
  }),
156
183
  },
157
184
  onchange: {
158
- product: onchange('sale.order.line', async (d) => {
185
+ product: onchange('sale.order.line', async (d, env) => {
159
186
  const p = await d.record('product');
160
187
  if (p === null)
161
188
  return;
162
189
  d.set('name', p.name);
163
190
  d.set('priceUnit', p.listPrice);
164
191
  d.set('uom', p.uom?.id ?? null);
165
- d.set('tax', p.tax?.id ?? null);
192
+ // The product's taxes, loaded (a draft's related record has its plain fields only).
193
+ const [taxed] = await env.model('product.product').browse([p.id]).load({ taxes: true });
194
+ d.set('taxes', { set: [...(taxed?.taxes.ids ?? [])] });
166
195
  }),
167
196
  },
168
197
  constraints: {
@@ -178,7 +207,17 @@ export const SaleOrderLine = defineModel('sale.order.line', {
178
207
  });
179
208
  export const salesShutro = defineModule({
180
209
  name: 'sales',
181
- version: '0.1.0',
210
+ version: '0.2.0',
211
+ migrations: [
212
+ {
213
+ version: '0.2.0',
214
+ description: 'Several taxes per line: the single tax of each line becomes its first',
215
+ up: async (ctx) => {
216
+ await ctx.sql `INSERT INTO sale_order_line_account_tax_rel (sale_order_line_id, account_tax_id)
217
+ SELECT id, tax_id FROM sale_order_line WHERE tax_id IS NOT NULL ON CONFLICT DO NOTHING`;
218
+ },
219
+ },
220
+ ],
182
221
  depends: [contactsShutro, productShutro, messagingShutro],
183
222
  groups: [
184
223
  orm.defineGroup({
@@ -93,7 +93,7 @@ export function generateClient(document) {
93
93
  const schemas = asSchema(asSchema(document['components'])['schemas']);
94
94
  const models = modelsOf(asSchema(document['paths']));
95
95
  const out = [];
96
- out.push(`// Generated by \`kosh generate client\` from ${JSON.stringify(String(info['title'] ?? 'Kosh API'))} ${String(info['version'] ?? '')}. Do not edit: regenerate.`, '', '// ------------------------------------------------------------------------------------------------- schemas', '');
96
+ out.push(`// Generated by \`shutro-erp generate client\` from ${JSON.stringify(String(info['title'] ?? 'Shutro ERP API'))} ${String(info['version'] ?? '')}. Do not edit: regenerate.`, '', '// ------------------------------------------------------------------------------------------------- schemas', '');
97
97
  for (const name of Object.keys(schemas).sort()) {
98
98
  const schema = asSchema(schemas[name]);
99
99
  const body = typeOf(schema);
@@ -282,7 +282,23 @@ export function openApiDocument(models, info) {
282
282
  openapi: '3.1.0',
283
283
  info,
284
284
  paths: Object.assign({}, ...list.map(modelPaths)),
285
+ // A session (sign-in) or an API key (RFC 0017 §5.1); cookie sessions also send X-CSRF-Token on changes.
286
+ security: [{ session: [] }, { apiKey: [] }, { cookie: [] }],
285
287
  components: {
288
+ securitySchemes: {
289
+ session: {
290
+ type: 'http',
291
+ scheme: 'bearer',
292
+ description: 'A session token from POST /api/v1/auth/login.',
293
+ },
294
+ apiKey: {
295
+ type: 'apiKey',
296
+ in: 'header',
297
+ name: 'X-Api-Key',
298
+ description: 'An API key (sk_…; also accepted as Authorization: Bearer). Its scopes — *, read:<app>, write:<app> — narrow the user’s rights.',
299
+ },
300
+ cookie: { type: 'apiKey', in: 'cookie', name: 'kosh_session' },
301
+ },
286
302
  schemas: {
287
303
  Problem: {
288
304
  type: 'object',
@@ -344,5 +360,5 @@ export function openApiDocument(models, info) {
344
360
  */
345
361
  export function openApiOf(modules) {
346
362
  const models = publicApiModels(buildKoshRegistry(modules));
347
- return openApiDocument(models.values(), { title: 'Kosh API', version: KOSH_VERSION });
363
+ return openApiDocument(models.values(), { title: 'Shutro ERP API', version: KOSH_VERSION });
348
364
  }
@@ -132,7 +132,7 @@ const digestOf = (rows) => createHash('sha256')
132
132
  */
133
133
  export async function installedModules(source, catalogue, states = ['installed']) {
134
134
  const driver = typeof source === 'string'
135
- ? pg.createDriver({ url: source, max: 1, applicationName: 'kosh apps' })
135
+ ? pg.createDriver({ url: source, max: 1, applicationName: 'shutro-erp apps' })
136
136
  : source;
137
137
  try {
138
138
  const rows = await readInstalledApps(driver);
@@ -188,7 +188,7 @@ export async function prepareApps(options) {
188
188
  await restoreAppMigrations(driver, options.dir);
189
189
  const pending = (await migrate.migrationStatus({ driver, dir: options.dir })).pending;
190
190
  if (pending.length > 0)
191
- throw new AppPlanError(`${pending.length} migration file(s) are not applied yet (${pending.map((f) => `${f.version}_${f.name}`).join(', ')}).`, 'Run kosh migrate first.');
191
+ throw new AppPlanError(`${pending.length} migration file(s) are not applied yet (${pending.map((f) => `${f.version}_${f.name}`).join(', ')}).`, 'Run shutro-erp migrate first.');
192
192
  const current = await installedApps(driver, catalogue);
193
193
  const plan = planApps(catalogue, current, options.change, options.protect ?? ['base']);
194
194
  const registryOf = (rows, states) => buildKoshRegistry(servedModules(catalogue, rows, states));
@@ -201,7 +201,7 @@ export async function prepareApps(options) {
201
201
  });
202
202
  // An upgrade is exactly that difference: the upgraded modules' code ahead of the database.
203
203
  if (drift.statements.length > 0 && plan.upgrade.length === 0)
204
- throw new AppPlanError(`The database does not match the installed modules (${drift.statements.length} change(s) not migrated).`, 'Run kosh migrate first.');
204
+ throw new AppPlanError(`The database does not match the installed modules (${drift.statements.length} change(s) not migrated).`, 'Run shutro-erp migrate first.');
205
205
  const runtime = registryOf(plan.after, ['installed']);
206
206
  const schema = registryOf(plan.after, ['installed', 'disabled']);
207
207
  const migration = await migrate.planMigration({
@@ -212,8 +212,8 @@ export async function prepareApps(options) {
212
212
  const removeData = options.removeData === true && plan.upgrade.length > 0;
213
213
  if (migration.destructive.length > 0 && !removeData)
214
214
  throw new AppPlanError(`The change would drop or alter data: ${migration.destructive.map((op) => migrate.renderOperation(op)).join('; ')}.`, plan.upgrade.length > 0
215
- ? 'Upgrade with "remove data" (kosh apps upgrade --remove-data) to drop them, or keep the fields in the code.'
216
- : 'App changes never destroy data; review the modules, or use kosh migrate --plan.');
215
+ ? 'Upgrade with "remove data" (shutro-erp apps upgrade --remove-data) to drop them, or keep the fields in the code.'
216
+ : 'App changes never destroy data; review the modules, or use shutro-erp migrate --plan.');
217
217
  // A disabled module's columns stay in other modules' tables: a NOT NULL one without a database default (a function
218
218
  // default or a compute lives in the ORM) would make every insert there fail.
219
219
  const orphaned = [];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * API keys and service accounts over HTTP (RFC 0017 §5.1). With a session only (a key cannot make keys):
3
+ *
4
+ * - `GET /api/v1/auth/api-keys` your keys (and every service account's, for administrators)
5
+ * - `POST /api/v1/auth/api-keys` `{ name, scopes, expiresInDays?, user? }` → the key and its secret, once
6
+ * - `POST /api/v1/auth/api-keys/:id/revoke` at once
7
+ * - `POST /api/v1/auth/service-accounts` `{ login, name, groups }` (administrators)
8
+ * - `GET /api/v1/auth/scopes` the scopes a key may have: `*` and the installed apps'
9
+ */
10
+ import type { FastifyInstance } from 'fastify';
11
+ import type { KoshRegistry } from '../registry.js';
12
+ import type { AuthService } from './service.js';
13
+ export declare function registerApiKeyRoutes(app: FastifyInstance, auth: AuthService, registry: KoshRegistry): void;
@@ -0,0 +1,65 @@
1
+ import { HttpProblem } from '../errors.js';
2
+ import { parseScopes } from './scopes.js';
3
+ const body = (request) => typeof request.body === 'object' && request.body !== null
4
+ ? request.body
5
+ : {};
6
+ export function registerApiKeyRoutes(app, auth, registry) {
7
+ const apps = () => new Set(registry.loaded.modules.map((m) => m.name));
8
+ const config = { config: { auth: false } };
9
+ app.get('/api/v1/auth/scopes', config, async (request) => {
10
+ await auth.requireSession(request);
11
+ return {
12
+ scopes: [
13
+ { scope: '*', label: 'Everything you may do' },
14
+ ...registry.loaded.modules.flatMap((m) => [
15
+ { scope: `read:${m.name}`, label: `Read ${m.name}` },
16
+ { scope: `write:${m.name}`, label: `Read and change ${m.name}` },
17
+ ]),
18
+ ],
19
+ };
20
+ });
21
+ app.get('/api/v1/auth/api-keys', config, async (request) => {
22
+ const session = await auth.requireSession(request);
23
+ return { keys: await auth.listApiKeys(session) };
24
+ });
25
+ app.post('/api/v1/auth/api-keys', config, async (request, reply) => {
26
+ const session = await auth.requireSession(request);
27
+ const input = body(request);
28
+ const scopes = parseScopes(Array.isArray(input['scopes'])
29
+ ? input['scopes'].map(String)
30
+ : String(input['scopes'] ?? ''), apps());
31
+ const user = input['user'] === undefined ? undefined : Number(input['user']);
32
+ if (user !== undefined && !(Number.isInteger(user) && user > 0))
33
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'user is a user id.');
34
+ const days = input['expiresInDays'] === undefined ? undefined : Number(input['expiresInDays']);
35
+ const created = await auth.createApiKey(session, {
36
+ name: String(input['name'] ?? ''),
37
+ scopes,
38
+ ...(user === undefined ? {} : { user }),
39
+ ...(days === undefined ? {} : { expiresInDays: days }),
40
+ }, request.ip);
41
+ return reply.status(201).send(created);
42
+ });
43
+ app.post('/api/v1/auth/api-keys/:id/revoke', config, async (request, reply) => {
44
+ const session = await auth.requireSession(request);
45
+ const id = Number(request.params.id);
46
+ if (!(Number.isInteger(id) && id > 0))
47
+ throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The key id is a positive integer.');
48
+ await auth.revokeApiKey(session, id, request.ip);
49
+ return reply.status(204).send();
50
+ });
51
+ app.post('/api/v1/auth/service-accounts', config, async (request, reply) => {
52
+ const session = await auth.requireSession(request);
53
+ const input = body(request);
54
+ const login = String(input['login'] ?? '').trim();
55
+ const name = String(input['name'] ?? '').trim();
56
+ if (!/^[A-Za-z0-9._@-]{3,64}$/.test(login) || name === '')
57
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'login (3–64 letters, digits, . _ @ -) and name are required.');
58
+ const groups = Array.isArray(input['groups']) ? input['groups'].map(String) : [];
59
+ const unknown = groups.filter((g) => !registry.registry.security.groups.has(g));
60
+ if (unknown.length > 0)
61
+ throw new HttpProblem(422, 'invalid-groups', 'Invalid roles', `Unknown roles: ${unknown.join(', ')}.`);
62
+ const created = await auth.createServiceAccount(session, { login, name, groups }, request.ip);
63
+ return reply.status(201).send(created);
64
+ });
65
+ }
@@ -20,6 +20,8 @@ const ResCompany = defineModel('res.company', {
20
20
  fields: {
21
21
  name: f.char({ required: true }),
22
22
  currency: f.many2one('res.currency', { required: true }),
23
+ /** How numbers are grouped on screen and in documents (RFC 0016): 1,234,567 or 12,34,567 (lakh and crore). */
24
+ numberGrouping: f.selection({ international: 'International (1,234,567)', lakh: 'Lakh and crore (12,34,567)' }, { required: true, default: 'international', label: 'Number grouping' }),
23
25
  },
24
26
  });
25
27
  const readRows = async (env, model, ids, keys) => env.asSuperuser({ reason: 'check administrators' }, async (su) => {
@@ -51,6 +53,8 @@ const ResUsers = defineModel('res.users', {
51
53
  companies: f.many2many('res.company'),
52
54
  lang: f.char({ default: 'en_US' }),
53
55
  tz: f.char({ default: 'UTC' }),
56
+ /** A service account (RFC 0017 §5.1): no password, no sign-in; acts only through API keys, with its roles. */
57
+ isService: f.boolean({ default: false, label: 'Service account' }),
54
58
  },
55
59
  });
56
60
  /** Password hash and TOTP state of a user. Private: never served by the generated API. */
@@ -79,6 +83,26 @@ const AuthSession = defineModel('auth.session', {
79
83
  userAgent: f.char(),
80
84
  },
81
85
  });
86
+ /**
87
+ * An API key (RFC 0017 §5.1): acts as its user (a person or a service account) within its scopes, until it expires
88
+ * or is revoked. The secret is shown once and stored only as a SHA-256 digest; the prefix identifies it. Private.
89
+ */
90
+ const AuthApiKey = defineModel('auth.api_key', {
91
+ fields: {
92
+ name: f.char({ required: true }),
93
+ user: f.many2one('res.users', { required: true, ondelete: 'cascade' }),
94
+ tokenHash: f.char({ required: true, index: 'unique' }),
95
+ /** The secret's start (`sk_ab12cd34`), to tell keys apart. */
96
+ prefix: f.char({ required: true }),
97
+ /** Space-separated: `*`, `read:<app>`, `write:<app>`. */
98
+ scopes: f.text({ required: true }),
99
+ expiresAt: f.datetime({ required: true }),
100
+ lastUsedAt: f.datetime(),
101
+ lastUsedIp: f.char(),
102
+ revoked: f.boolean({ default: false }),
103
+ issuedBy: f.many2one('res.users', { ondelete: 'set null' }),
104
+ },
105
+ });
82
106
  /**
83
107
  * A group or role a user holds in one company (or every company when `company` is empty). Private. Users holding
84
108
  * any cannot be deleted (`restrict`): they are archived, which the "keep one administrator" guard checks.
@@ -169,6 +193,7 @@ export const baseShutro = defineModule({
169
193
  ResUsers,
170
194
  AuthCredential,
171
195
  AuthSession,
196
+ AuthApiKey,
172
197
  AuthUserGroup,
173
198
  AuthUserGrant,
174
199
  AuditEvent,
@@ -186,6 +211,7 @@ export const base = defineKoshModule({
186
211
  privateModels: [
187
212
  'auth.credential',
188
213
  'auth.session',
214
+ 'auth.api_key',
189
215
  'auth.user_group',
190
216
  'auth.user_grant',
191
217
  'audit.event',
@@ -94,6 +94,6 @@ export function verifyTotp(secret, code, time = Date.now()) {
94
94
  });
95
95
  }
96
96
  /** The `otpauth://` URI authenticator apps scan. */
97
- export function otpauthUri(secret, account, issuer = 'Kosh') {
97
+ export function otpauthUri(secret, account, issuer = 'Shutro ERP') {
98
98
  return `otpauth://totp/${encodeURIComponent(`${issuer}:${account}`)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
99
99
  }
@@ -0,0 +1,12 @@
1
+ export type Access = 'read' | 'write';
2
+ /** The scopes in `text` (space or comma separated), checked against the installed apps. */
3
+ export declare function parseScopes(input: string | readonly string[], apps: ReadonlySet<string>): string[];
4
+ /** Whether `scopes` allow `access` to `app` (`null`: a route of no app, which needs `*`). */
5
+ export declare function scopesAllow(scopes: readonly string[], app: string | null, access: Access): boolean;
6
+ /** The access a request method needs. */
7
+ export declare const accessOf: (method: string) => Access;
8
+ /**
9
+ * Refuses a request an API key's scopes do not cover. `route` is the route's pattern (`/api/v1/models/:model`),
10
+ * `app` the app it belongs to, or null.
11
+ */
12
+ export declare function enforceScopes(scopes: readonly string[], method: string, route: string, app: string | null): void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * API key scopes (RFC 0017 §5.1): they narrow what a key's user may do, never widen it. `*` is everything the user
3
+ * may do; `read:<app>` reads an app's models and routes; `write:<app>` also changes them (it implies read). A route
4
+ * belongs to an app when the app's module registered it, or when it serves one of the app's models; a route of no
5
+ * app needs `*`. Keys cannot manage credentials (sign-in, 2FA, passwords, keys): those need a session.
6
+ */
7
+ import { HttpProblem } from '../errors.js';
8
+ const SCOPE = /^(\*|(read|write):[a-z][a-z0-9_]*)$/;
9
+ /** The scopes in `text` (space or comma separated), checked against the installed apps. */
10
+ export function parseScopes(input, apps) {
11
+ const list = (typeof input === 'string' ? input.split(/[\s,]+/) : [...input])
12
+ .map((s) => s.trim())
13
+ .filter((s) => s !== '');
14
+ if (list.length === 0)
15
+ throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', 'Give at least one scope: *, read:<app> or write:<app>.');
16
+ for (const scope of list) {
17
+ if (!SCOPE.test(scope))
18
+ throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', `"${scope}" is not a scope: *, read:<app> or write:<app>.`);
19
+ const app = scope.split(':')[1];
20
+ if (app !== undefined && !apps.has(app))
21
+ throw new HttpProblem(400, 'invalid-scopes', 'Invalid scopes', `"${app}" is not an installed app.`);
22
+ }
23
+ return [...new Set(list)].sort();
24
+ }
25
+ /** Whether `scopes` allow `access` to `app` (`null`: a route of no app, which needs `*`). */
26
+ export function scopesAllow(scopes, app, access) {
27
+ if (scopes.includes('*'))
28
+ return true;
29
+ if (app === null)
30
+ return false;
31
+ return scopes.includes(`write:${app}`) || (access === 'read' && scopes.includes(`read:${app}`));
32
+ }
33
+ /** The access a request method needs. */
34
+ export const accessOf = (method) => method === 'GET' || method === 'HEAD' || method === 'OPTIONS' ? 'read' : 'write';
35
+ /** Credential routes an API key may still use (who am I). */
36
+ const KEY_AUTH_ROUTES = new Set(['GET /api/v1/auth/me']);
37
+ /**
38
+ * Refuses a request an API key's scopes do not cover. `route` is the route's pattern (`/api/v1/models/:model`),
39
+ * `app` the app it belongs to, or null.
40
+ */
41
+ export function enforceScopes(scopes, method, route, app) {
42
+ if (route.startsWith('/api/v1/auth/') && !KEY_AUTH_ROUTES.has(`${method} ${route}`))
43
+ throw new HttpProblem(403, 'api-key-not-allowed', 'Not with an API key', 'Signing in, two-factor, passwords and API keys are managed with a session, not an API key.');
44
+ if (KEY_AUTH_ROUTES.has(`${method} ${route}`))
45
+ return;
46
+ const access = accessOf(method);
47
+ if (!scopesAllow(scopes, app, access))
48
+ throw new HttpProblem(403, 'scope', 'Outside the API key’s scopes', app === null
49
+ ? 'This route belongs to no app: it needs a key with the * scope.'
50
+ : `This needs the ${access === 'read' ? `read:${app} or write:${app}` : `write:${app}`} scope.`, 'Create a key with the scope it needs.');
51
+ }
@@ -37,12 +37,28 @@ export interface UserInfo {
37
37
  readonly name: string;
38
38
  readonly company: number | null;
39
39
  readonly companies: readonly number[];
40
+ /** The number grouping of each of the user's companies (RFC 0016): `lakh` or `international`. */
41
+ readonly groupings: Readonly<Record<string, 'international' | 'lakh'>>;
40
42
  readonly twoFactorEnabled: boolean;
41
43
  /** Holds an administrator role (in some company). */
42
44
  readonly administrator: boolean;
43
45
  /** Administrator rights are withheld until this session has a second factor (enable or verify 2FA). */
44
46
  readonly administratorNeedsSecondFactor: boolean;
45
47
  }
48
+ /** An API key as listed (never its secret). */
49
+ export interface ApiKeyInfo {
50
+ readonly id: number;
51
+ readonly name: string;
52
+ readonly user: number;
53
+ /** The user's name (yours, or a service account's). */
54
+ readonly userName: string;
55
+ readonly prefix: string;
56
+ readonly scopes: readonly string[];
57
+ readonly expiresAt: string;
58
+ readonly lastUsedAt: string | null;
59
+ readonly lastUsedIp: string | null;
60
+ readonly revoked: boolean;
61
+ }
46
62
  export declare const SESSION_COOKIE = "kosh_session";
47
63
  /** Milliseconds of a datetime value as Shutro returns it (`Date`, or an ISO text), or `null`. */
48
64
  export declare function timeOf(value: unknown): number | null;
@@ -81,8 +97,50 @@ export declare class AuthService {
81
97
  requireSession(request: FastifyRequest, options?: {
82
98
  readonly allowPending?: boolean;
83
99
  }): Promise<SessionInfo>;
84
- /** Authenticator for the server (RFC 0002 §6.2): the session's user, company from `X-Company`, groups for it. */
100
+ /**
101
+ * Authenticator for the server (RFC 0002 §6.2): the session's user — or the API key's (RFC 0017 §5.1), with its
102
+ * scopes —, company from `X-Company`, groups for it.
103
+ */
85
104
  authenticate(request: FastifyRequest): Promise<RequestIdentity | null>;
105
+ /**
106
+ * The request's API key (RFC 0017 §5.1): `X-Api-Key: sk_…` or `Authorization: Bearer sk_…`; null without one.
107
+ * @throws {HttpProblem} 401 for an `X-Api-Key` that is unknown, revoked or expired
108
+ */
109
+ apiKeyOf(request: FastifyRequest): Promise<{
110
+ id: number;
111
+ user: number;
112
+ scopes: string[];
113
+ } | null>;
114
+ /**
115
+ * A new API key for `user` (the session's own, or a service account's for administrators), with `scopes` already
116
+ * checked against the installed apps. The secret is returned once.
117
+ */
118
+ createApiKey(session: SessionInfo, input: {
119
+ readonly user?: number;
120
+ readonly name: string;
121
+ readonly scopes: readonly string[];
122
+ readonly expiresInDays?: number;
123
+ }, ip?: string): Promise<ApiKeyInfo & {
124
+ token: string;
125
+ }>;
126
+ /** The session user's keys, and every service account's for administrators. */
127
+ listApiKeys(session: SessionInfo): Promise<ApiKeyInfo[]>;
128
+ /** Revokes a key at once (the session's own, or a service account's for administrators). */
129
+ revokeApiKey(session: SessionInfo, id: number, ip?: string): Promise<void>;
130
+ /**
131
+ * A service account (RFC 0017 §5.1): a user without a password who acts only through API keys, with `groups`
132
+ * (checked by the caller) in the administrator's companies. Administrators only.
133
+ */
134
+ createServiceAccount(session: SessionInfo, input: {
135
+ readonly login: string;
136
+ readonly name: string;
137
+ readonly groups: readonly string[];
138
+ }, ip?: string): Promise<{
139
+ id: number;
140
+ login: string;
141
+ name: string;
142
+ groups: string[];
143
+ }>;
86
144
  /** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
87
145
  verify(uid: number): Promise<VerifiedIdentity | null>;
88
146
  /** The signed-in user's state. */