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
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
557
|
+
companies: companyIds,
|
|
558
|
+
groupings,
|
|
329
559
|
twoFactorEnabled,
|
|
330
560
|
administrator,
|
|
331
561
|
administratorNeedsSecondFactor: administrator && !(session.secondFactor && twoFactorEnabled),
|
package/dist/server/demo.d.ts
CHANGED
|
@@ -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 = "
|
|
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;
|
package/dist/server/demo.js
CHANGED
|
@@ -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 = '
|
|
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,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
|
+
}
|
package/dist/server/host.js
CHANGED
|
@@ -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({
|
|
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
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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';
|
package/dist/server/registry.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/server/server.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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(), {
|
|
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
|
-
|
|
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) => {
|
package/dist/settings/routes.js
CHANGED
|
@@ -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
|
|
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;
|