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.
- package/README.md +74 -67
- package/dist/account_portal/.kosh/bindings.d.ts +22 -0
- package/dist/account_portal/.kosh/bindings.js +15 -0
- package/dist/account_portal/.kosh/schema.d.ts +1380 -0
- package/dist/account_portal/index.d.ts +3 -0
- package/dist/account_portal/index.js +73 -0
- package/dist/account_portal/module.d.ts +5983 -0
- package/dist/account_portal/module.js +18 -0
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +172 -9
- package/dist/accounting/chart.d.ts +40 -8
- package/dist/accounting/chart.js +124 -30
- package/dist/accounting/closing.d.ts +6 -1
- package/dist/accounting/closing.js +5 -1
- package/dist/accounting/demo.js +6 -5
- package/dist/accounting/index.d.ts +3 -3
- package/dist/accounting/index.js +200 -23
- package/dist/accounting/module.d.ts +3326 -1086
- package/dist/accounting/module.js +408 -66
- package/dist/accounting/payments.d.ts +25 -0
- package/dist/accounting/payments.js +96 -4
- package/dist/accounting/web/RegisterPayment.d.ts +3 -2
- package/dist/accounting/web/RegisterPayment.js +39 -6
- package/dist/accounting/web/SettingsPage.js +5 -1
- package/dist/accounting/web/messages.js +10 -0
- package/dist/assistant/routes.js +1 -1
- package/dist/automation/engine.js +1 -1
- package/dist/automation/web/messages.js +2 -2
- package/dist/cli/apps.d.ts +1 -1
- package/dist/cli/apps.js +14 -14
- package/dist/cli/bin.js +3 -0
- package/dist/cli/index.js +11 -11
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.js +224 -78
- package/dist/cli/launch.d.ts +16 -0
- package/dist/cli/launch.js +138 -0
- package/dist/cli/project.d.ts +7 -7
- package/dist/cli/project.js +25 -25
- package/dist/cli/prompt.d.ts +35 -0
- package/dist/cli/prompt.js +48 -2
- package/dist/cli/templates.d.ts +14 -6
- package/dist/cli/templates.js +61 -34
- package/dist/cli/tui.d.ts +7 -0
- package/dist/cli/tui.js +254 -0
- package/dist/cli/ui.d.ts +21 -8
- package/dist/cli/ui.js +65 -19
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +33 -1
- package/dist/contacts/index.js +24 -1
- package/dist/contacts/module.d.ts +42 -0
- package/dist/contacts/module.js +3 -1
- package/dist/import/convert.d.ts +55 -0
- package/dist/import/convert.js +224 -0
- package/dist/import/csv.d.ts +28 -0
- package/dist/import/csv.js +115 -0
- package/dist/import/index.d.ts +33 -0
- package/dist/import/index.js +60 -0
- package/dist/import/mapping.d.ts +15 -0
- package/dist/import/mapping.js +33 -0
- package/dist/import/match.d.ts +24 -0
- package/dist/import/match.js +60 -0
- package/dist/import/models.d.ts +2 -0
- package/dist/import/models.js +102 -0
- package/dist/import/routes.d.ts +13 -0
- package/dist/import/routes.js +392 -0
- package/dist/import/run.d.ts +131 -0
- package/dist/import/run.js +568 -0
- package/dist/import/service.d.ts +153 -0
- package/dist/import/service.js +367 -0
- package/dist/import/web/ImportButton.d.ts +7 -0
- package/dist/import/web/ImportButton.js +31 -0
- package/dist/import/web/ImportWizard.d.ts +75 -0
- package/dist/import/web/ImportWizard.js +217 -0
- package/dist/import/web/index.d.ts +13 -0
- package/dist/import/web/index.js +18 -0
- package/dist/import/web/messages.d.ts +2 -0
- package/dist/import/web/messages.js +139 -0
- package/dist/import/worker.d.ts +3 -0
- package/dist/import/worker.js +52 -0
- package/dist/import/xlsx.d.ts +20 -0
- package/dist/import/xlsx.js +192 -0
- package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_bd/.kosh/bindings.js +15 -0
- package/dist/l10n_bd/.kosh/schema.d.ts +1311 -0
- package/dist/l10n_bd/documents.d.ts +8 -0
- package/dist/l10n_bd/documents.js +183 -0
- package/dist/l10n_bd/index.d.ts +8 -0
- package/dist/l10n_bd/index.js +156 -0
- package/dist/l10n_bd/module.d.ts +5705 -0
- package/dist/l10n_bd/module.js +163 -0
- package/dist/l10n_bd/mushak.d.ts +67 -0
- package/dist/l10n_bd/mushak.js +139 -0
- package/dist/l10n_bd/routes.d.ts +3 -0
- package/dist/l10n_bd/routes.js +205 -0
- package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
- package/dist/l10n_bd/web/MushakPage.js +53 -0
- package/dist/l10n_bd/web/index.d.ts +13 -0
- package/dist/l10n_bd/web/index.js +18 -0
- package/dist/l10n_bd/web/messages.d.ts +2 -0
- package/dist/l10n_bd/web/messages.js +101 -0
- package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
- package/dist/l10n_in/.kosh/bindings.js +15 -0
- package/dist/l10n_in/.kosh/schema.d.ts +1373 -0
- package/dist/l10n_in/documents.d.ts +8 -0
- package/dist/l10n_in/documents.js +114 -0
- package/dist/l10n_in/einvoice.d.ts +253 -0
- package/dist/l10n_in/einvoice.js +485 -0
- package/dist/l10n_in/gstr.d.ts +109 -0
- package/dist/l10n_in/gstr.js +430 -0
- package/dist/l10n_in/index.d.ts +20 -0
- package/dist/l10n_in/index.js +232 -0
- package/dist/l10n_in/module.d.ts +7823 -0
- package/dist/l10n_in/module.js +427 -0
- package/dist/l10n_in/routes-einvoice.d.ts +15 -0
- package/dist/l10n_in/routes-einvoice.js +124 -0
- package/dist/l10n_in/routes.d.ts +17 -0
- package/dist/l10n_in/routes.js +282 -0
- package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
- package/dist/l10n_in/web/EInvoicePanel.js +63 -0
- package/dist/l10n_in/web/GstPage.d.ts +7 -0
- package/dist/l10n_in/web/GstPage.js +57 -0
- package/dist/l10n_in/web/GstSettings.d.ts +10 -0
- package/dist/l10n_in/web/GstSettings.js +36 -0
- package/dist/l10n_in/web/index.d.ts +15 -0
- package/dist/l10n_in/web/index.js +22 -0
- package/dist/l10n_in/web/messages.d.ts +2 -0
- package/dist/l10n_in/web/messages.js +166 -0
- package/dist/mail/index.js +2 -2
- package/dist/modules-sdk/demo.d.ts +2 -0
- package/dist/modules-sdk/demo.js +7 -3
- package/dist/modules-sdk/index.d.ts +2 -2
- package/dist/modules-sdk/index.js +1 -1
- package/dist/modules-sdk/loader.js +2 -2
- package/dist/modules-sdk/manifest.d.ts +132 -0
- package/dist/modules-sdk/manifest.js +22 -0
- package/dist/notify/inbox.js +21 -0
- package/dist/notify/messages.js +7 -3
- package/dist/notify/models.d.ts +1 -1
- package/dist/notify/models.js +2 -0
- package/dist/notify/routes.js +1 -1
- package/dist/notify/web/messages.js +5 -1
- package/dist/portal/.kosh/bindings.d.ts +22 -0
- package/dist/portal/.kosh/bindings.js +15 -0
- package/dist/portal/.kosh/schema.d.ts +376 -0
- package/dist/portal/announce.d.ts +16 -0
- package/dist/portal/announce.js +117 -0
- package/dist/portal/documents.d.ts +17 -0
- package/dist/portal/documents.js +61 -0
- package/dist/portal/index.d.ts +20 -0
- package/dist/portal/index.js +65 -0
- package/dist/portal/module.d.ts +1405 -0
- package/dist/portal/module.js +84 -0
- package/dist/portal/routes-documents.d.ts +2 -0
- package/dist/portal/routes-documents.js +391 -0
- package/dist/portal/routes.d.ts +13 -0
- package/dist/portal/routes.js +313 -0
- package/dist/portal/rules.d.ts +11 -0
- package/dist/portal/rules.js +25 -0
- package/dist/portal/web/PortalAccess.d.ts +8 -0
- package/dist/portal/web/PortalAccess.js +66 -0
- package/dist/portal/web/index.d.ts +12 -0
- package/dist/portal/web/index.js +17 -0
- package/dist/portal/web/messages.d.ts +2 -0
- package/dist/portal/web/messages.js +33 -0
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +42 -3
- package/dist/product/demo.js +6 -5
- package/dist/product/index.d.ts +1 -0
- package/dist/product/index.js +17 -4
- package/dist/product/module.d.ts +346 -14
- package/dist/product/module.js +48 -18
- package/dist/product/taxes.d.ts +59 -0
- package/dist/product/taxes.js +61 -0
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +33 -1
- package/dist/project/module.d.ts +20 -0
- package/dist/project_portal/.kosh/bindings.d.ts +22 -0
- package/dist/project_portal/.kosh/bindings.js +15 -0
- package/dist/project_portal/.kosh/schema.d.ts +701 -0
- package/dist/project_portal/index.d.ts +9 -0
- package/dist/project_portal/index.js +42 -0
- package/dist/project_portal/module.d.ts +2142 -0
- package/dist/project_portal/module.js +22 -0
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +45 -4
- package/dist/purchase/demo.js +3 -3
- package/dist/purchase/index.js +1 -1
- package/dist/purchase/module.d.ts +232 -8
- package/dist/purchase/module.js +51 -9
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +174 -10
- package/dist/purchase_account/module.d.ts +2008 -641
- package/dist/purchase_account/module.js +2 -2
- package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
- package/dist/purchase_portal/.kosh/bindings.js +15 -0
- package/dist/purchase_portal/.kosh/schema.d.ts +852 -0
- package/dist/purchase_portal/index.d.ts +9 -0
- package/dist/purchase_portal/index.js +53 -0
- package/dist/purchase_portal/module.d.ts +2758 -0
- package/dist/purchase_portal/module.js +18 -0
- package/dist/reporting/documents.js +20 -3
- package/dist/reporting/index.d.ts +1 -0
- package/dist/reporting/index.js +1 -0
- package/dist/reporting/routes-documents.js +9 -2
- package/dist/reporting/words.d.ts +11 -0
- package/dist/reporting/words.js +229 -0
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +174 -10
- package/dist/sale_account/module.d.ts +2008 -641
- package/dist/sale_account/module.js +3 -3
- package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
- package/dist/sale_portal/.kosh/bindings.js +15 -0
- package/dist/sale_portal/.kosh/schema.d.ts +766 -0
- package/dist/sale_portal/index.d.ts +9 -0
- package/dist/sale_portal/index.js +71 -0
- package/dist/sale_portal/module.d.ts +2718 -0
- package/dist/sale_portal/module.js +19 -0
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +45 -4
- package/dist/sale_project/module.d.ts +390 -10
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +45 -4
- package/dist/sales/demo.js +3 -3
- package/dist/sales/index.js +67 -1
- package/dist/sales/module.d.ts +232 -8
- package/dist/sales/module.js +51 -12
- package/dist/server/api/client.js +1 -1
- package/dist/server/api/openapi.js +17 -1
- package/dist/server/apps.js +5 -5
- package/dist/server/auth/api-keys.d.ts +13 -0
- package/dist/server/auth/api-keys.js +65 -0
- package/dist/server/auth/base.js +26 -0
- package/dist/server/auth/crypto.js +1 -1
- package/dist/server/auth/scopes.d.ts +12 -0
- package/dist/server/auth/scopes.js +51 -0
- package/dist/server/auth/service.d.ts +59 -1
- package/dist/server/auth/service.js +236 -6
- package/dist/server/demo.d.ts +1 -1
- package/dist/server/demo.js +1 -1
- package/dist/server/env-names.d.ts +2 -0
- package/dist/server/env-names.js +16 -0
- package/dist/server/host.js +5 -1
- package/dist/server/identity.d.ts +7 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/registry.js +1 -1
- package/dist/server/server.d.ts +5 -0
- package/dist/server/server.js +71 -6
- package/dist/settings/routes.js +1 -1
- package/dist/settings/web/ApiKeysPage.d.ts +8 -0
- package/dist/settings/web/ApiKeysPage.js +70 -0
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +4 -0
- package/dist/settings/web/messages.js +62 -0
- package/dist/storage/routes.js +1 -1
- package/dist/studio/build.js +1 -1
- package/dist/studio/generate.js +4 -4
- package/dist/studio/module.js +5 -5
- package/dist/studio/web/messages.js +2 -2
- package/dist/ui/api.d.ts +2 -0
- package/dist/ui/app.js +27 -4
- package/dist/ui/context.d.ts +3 -1
- package/dist/ui/context.js +2 -2
- package/dist/ui/i18n/messages.js +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/lib/format.d.ts +2 -0
- package/dist/ui/lib/format.js +3 -1
- package/dist/ui/pwa/worker.js +2 -2
- package/dist/ui/registry.d.ts +15 -0
- package/dist/ui/registry.js +20 -0
- package/dist/ui/views/FormView.js +19 -6
- package/package.json +52 -4
package/dist/sales/module.js
CHANGED
|
@@ -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
|
-
|
|
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', [
|
|
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.
|
|
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', [
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 \`
|
|
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: '
|
|
363
|
+
return openApiDocument(models.values(), { title: 'Shutro ERP API', version: KOSH_VERSION });
|
|
348
364
|
}
|
package/dist/server/apps.js
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
|
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" (
|
|
216
|
-
: 'App changes never destroy data; review the modules, or use
|
|
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
|
+
}
|
package/dist/server/auth/base.js
CHANGED
|
@@ -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 = '
|
|
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
|
-
/**
|
|
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. */
|