shutro-erp 0.3.0 → 0.3.2

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 (137) hide show
  1. package/README.md +58 -58
  2. package/dist/account_portal/.kosh/bindings.js +1 -1
  3. package/dist/account_portal/.kosh/schema.d.ts +31 -1
  4. package/dist/account_portal/index.js +3 -2
  5. package/dist/accounting/.kosh/bindings.js +1 -1
  6. package/dist/accounting/.kosh/schema.d.ts +31 -1
  7. package/dist/accounting/index.js +4 -0
  8. package/dist/assistant/routes.js +1 -1
  9. package/dist/automation/engine.js +1 -1
  10. package/dist/automation/web/messages.js +2 -2
  11. package/dist/cli/apps.d.ts +1 -1
  12. package/dist/cli/apps.js +14 -14
  13. package/dist/cli/bin.js +3 -0
  14. package/dist/cli/index.js +11 -11
  15. package/dist/cli/init.d.ts +1 -1
  16. package/dist/cli/init.js +60 -36
  17. package/dist/cli/launch.js +10 -7
  18. package/dist/cli/project.d.ts +7 -7
  19. package/dist/cli/project.js +25 -25
  20. package/dist/cli/prompt.js +2 -2
  21. package/dist/cli/templates.d.ts +3 -3
  22. package/dist/cli/templates.js +33 -28
  23. package/dist/cli/ui.d.ts +1 -8
  24. package/dist/cli/ui.js +36 -12
  25. package/dist/contacts/.kosh/bindings.js +1 -1
  26. package/dist/contacts/.kosh/schema.d.ts +31 -1
  27. package/dist/integrations/inbound.d.ts +36 -0
  28. package/dist/integrations/inbound.js +171 -0
  29. package/dist/integrations/index.d.ts +18 -0
  30. package/dist/integrations/index.js +400 -0
  31. package/dist/integrations/models.d.ts +6 -0
  32. package/dist/integrations/models.js +119 -0
  33. package/dist/integrations/web/DeliveryTools.d.ts +4 -0
  34. package/dist/integrations/web/DeliveryTools.js +15 -0
  35. package/dist/integrations/web/EndpointTools.d.ts +7 -0
  36. package/dist/integrations/web/EndpointTools.js +25 -0
  37. package/dist/integrations/web/InboundTools.d.ts +4 -0
  38. package/dist/integrations/web/InboundTools.js +15 -0
  39. package/dist/integrations/web/WebhookTools.d.ts +7 -0
  40. package/dist/integrations/web/WebhookTools.js +25 -0
  41. package/dist/integrations/web/index.d.ts +16 -0
  42. package/dist/integrations/web/index.js +27 -0
  43. package/dist/integrations/web/messages.d.ts +2 -0
  44. package/dist/integrations/web/messages.js +53 -0
  45. package/dist/integrations/webhooks.d.ts +63 -0
  46. package/dist/integrations/webhooks.js +217 -0
  47. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  48. package/dist/l10n_bd/.kosh/schema.d.ts +31 -1
  49. package/dist/l10n_in/.kosh/bindings.js +1 -1
  50. package/dist/l10n_in/.kosh/schema.d.ts +31 -1
  51. package/dist/l10n_in/einvoice.js +1 -1
  52. package/dist/l10n_in/web/messages.js +2 -2
  53. package/dist/mail/index.js +2 -2
  54. package/dist/modules-sdk/index.d.ts +1 -1
  55. package/dist/modules-sdk/loader.js +2 -2
  56. package/dist/modules-sdk/manifest.d.ts +48 -0
  57. package/dist/notify/messages.js +3 -3
  58. package/dist/notify/routes.js +1 -1
  59. package/dist/notify/web/messages.js +1 -1
  60. package/dist/payment/.kosh/bindings.d.ts +22 -0
  61. package/dist/payment/.kosh/bindings.js +15 -0
  62. package/dist/payment/.kosh/schema.d.ts +1553 -0
  63. package/dist/payment/index.d.ts +34 -0
  64. package/dist/payment/index.js +375 -0
  65. package/dist/payment/module.d.ts +6853 -0
  66. package/dist/payment/module.js +74 -0
  67. package/dist/payment/providers.d.ts +64 -0
  68. package/dist/payment/providers.js +77 -0
  69. package/dist/payment/settle.d.ts +11 -0
  70. package/dist/payment/settle.js +59 -0
  71. package/dist/payment/sslcommerz.d.ts +12 -0
  72. package/dist/payment/sslcommerz.js +185 -0
  73. package/dist/portal/.kosh/bindings.js +1 -1
  74. package/dist/portal/.kosh/schema.d.ts +31 -1
  75. package/dist/portal/routes-documents.js +5 -0
  76. package/dist/product/.kosh/bindings.js +1 -1
  77. package/dist/product/.kosh/schema.d.ts +31 -1
  78. package/dist/project/.kosh/bindings.js +1 -1
  79. package/dist/project/.kosh/schema.d.ts +31 -1
  80. package/dist/project_portal/.kosh/bindings.js +1 -1
  81. package/dist/project_portal/.kosh/schema.d.ts +31 -1
  82. package/dist/purchase/.kosh/bindings.js +1 -1
  83. package/dist/purchase/.kosh/schema.d.ts +31 -1
  84. package/dist/purchase_account/.kosh/bindings.js +1 -1
  85. package/dist/purchase_account/.kosh/schema.d.ts +31 -1
  86. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  87. package/dist/purchase_portal/.kosh/schema.d.ts +31 -1
  88. package/dist/sale_account/.kosh/bindings.js +1 -1
  89. package/dist/sale_account/.kosh/schema.d.ts +31 -1
  90. package/dist/sale_portal/.kosh/bindings.js +1 -1
  91. package/dist/sale_portal/.kosh/schema.d.ts +31 -1
  92. package/dist/sale_project/.kosh/bindings.js +1 -1
  93. package/dist/sale_project/.kosh/schema.d.ts +31 -1
  94. package/dist/sales/.kosh/bindings.js +1 -1
  95. package/dist/sales/.kosh/schema.d.ts +31 -1
  96. package/dist/sales/index.js +2 -0
  97. package/dist/server/api/client.js +1 -1
  98. package/dist/server/api/openapi.js +17 -1
  99. package/dist/server/api/rest.d.ts +6 -1
  100. package/dist/server/api/rest.js +11 -0
  101. package/dist/server/apps.js +5 -5
  102. package/dist/server/auth/api-keys.d.ts +13 -0
  103. package/dist/server/auth/api-keys.js +65 -0
  104. package/dist/server/auth/base.js +24 -0
  105. package/dist/server/auth/crypto.js +1 -1
  106. package/dist/server/auth/scopes.d.ts +12 -0
  107. package/dist/server/auth/scopes.js +51 -0
  108. package/dist/server/auth/service.d.ts +57 -1
  109. package/dist/server/auth/service.js +229 -5
  110. package/dist/server/demo.d.ts +1 -1
  111. package/dist/server/demo.js +1 -1
  112. package/dist/server/env-names.d.ts +2 -0
  113. package/dist/server/env-names.js +16 -0
  114. package/dist/server/host.js +5 -1
  115. package/dist/server/identity.d.ts +7 -0
  116. package/dist/server/index.d.ts +2 -1
  117. package/dist/server/index.js +2 -1
  118. package/dist/server/registry.js +1 -1
  119. package/dist/server/server.d.ts +5 -0
  120. package/dist/server/server.js +45 -5
  121. package/dist/settings/routes.js +1 -1
  122. package/dist/settings/web/ApiKeysPage.d.ts +8 -0
  123. package/dist/settings/web/ApiKeysPage.js +70 -0
  124. package/dist/settings/web/index.d.ts +1 -0
  125. package/dist/settings/web/index.js +4 -0
  126. package/dist/settings/web/messages.js +62 -0
  127. package/dist/storage/routes.js +1 -1
  128. package/dist/studio/build.js +1 -1
  129. package/dist/studio/generate.js +4 -4
  130. package/dist/studio/module.js +5 -5
  131. package/dist/studio/web/messages.js +2 -2
  132. package/dist/ui/app.js +26 -3
  133. package/dist/ui/i18n/messages.js +2 -2
  134. package/dist/ui/pwa/worker.js +2 -2
  135. package/dist/ui/registry.d.ts +15 -0
  136. package/dist/ui/registry.js +20 -0
  137. package/package.json +15 -3
@@ -102,6 +102,17 @@ function ifMatch(request) {
102
102
  throw problem(400, 'invalid-if-match', 'Invalid If-Match', 'If-Match must be an ETag returned by this API.');
103
103
  return new Date(Number(m[1]));
104
104
  }
105
+ /**
106
+ * A record as `GET /api/v1/models/<model>/<id>` returns it to `env`'s user (their readable default fields), or null
107
+ * when they cannot see it: what webhooks send (RFC 0017 §5.3).
108
+ */
109
+ export async function readApiRecord(env, model, id) {
110
+ const set = await ref(env, model.name).search({ where: { id }, limit: 1 });
111
+ if (set.length === 0)
112
+ return null;
113
+ const [row] = await rows(set, fieldsOf(model, env, undefined));
114
+ return row ?? null;
115
+ }
105
116
  async function record(env, model, id, fields) {
106
117
  const set = await ref(env, model.name).browse([id]).load(['id', 'updatedAt']);
107
118
  const [row] = await rows(set, fields ?? fieldsOf(model, env, undefined));
@@ -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
+ }
@@ -53,6 +53,8 @@ const ResUsers = defineModel('res.users', {
53
53
  companies: f.many2many('res.company'),
54
54
  lang: f.char({ default: 'en_US' }),
55
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' }),
56
58
  },
57
59
  });
58
60
  /** Password hash and TOTP state of a user. Private: never served by the generated API. */
@@ -81,6 +83,26 @@ const AuthSession = defineModel('auth.session', {
81
83
  userAgent: f.char(),
82
84
  },
83
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
+ });
84
106
  /**
85
107
  * A group or role a user holds in one company (or every company when `company` is empty). Private. Users holding
86
108
  * any cannot be deleted (`restrict`): they are archived, which the "keep one administrator" guard checks.
@@ -171,6 +193,7 @@ export const baseShutro = defineModule({
171
193
  ResUsers,
172
194
  AuthCredential,
173
195
  AuthSession,
196
+ AuthApiKey,
174
197
  AuthUserGroup,
175
198
  AuthUserGrant,
176
199
  AuditEvent,
@@ -188,6 +211,7 @@ export const base = defineKoshModule({
188
211
  privateModels: [
189
212
  'auth.credential',
190
213
  'auth.session',
214
+ 'auth.api_key',
191
215
  'auth.user_group',
192
216
  'auth.user_grant',
193
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
+ }
@@ -45,6 +45,20 @@ export interface UserInfo {
45
45
  /** Administrator rights are withheld until this session has a second factor (enable or verify 2FA). */
46
46
  readonly administratorNeedsSecondFactor: boolean;
47
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
+ }
48
62
  export declare const SESSION_COOKIE = "kosh_session";
49
63
  /** Milliseconds of a datetime value as Shutro returns it (`Date`, or an ISO text), or `null`. */
50
64
  export declare function timeOf(value: unknown): number | null;
@@ -83,8 +97,50 @@ export declare class AuthService {
83
97
  requireSession(request: FastifyRequest, options?: {
84
98
  readonly allowPending?: boolean;
85
99
  }): Promise<SessionInfo>;
86
- /** 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
+ */
87
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
+ }>;
88
144
  /** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
89
145
  verify(uid: number): Promise<VerifiedIdentity | null>;
90
146
  /** The signed-in user's state. */
@@ -36,6 +36,7 @@ export class AuthService {
36
36
  #shutro;
37
37
  #options;
38
38
  #cache = new Map();
39
+ #keyCache = new Map();
39
40
  #dummy = hashPassword(randomToken());
40
41
  constructor(shutro, options = {}) {
41
42
  this.#shutro = shutro;
@@ -212,14 +213,27 @@ export class AuthService {
212
213
  throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
213
214
  return session;
214
215
  }
215
- /** Authenticator for the server (RFC 0002 §6.2): the session's user, company from `X-Company`, groups for it. */
216
+ /**
217
+ * Authenticator for the server (RFC 0002 §6.2): the session's user — or the API key's (RFC 0017 §5.1), with its
218
+ * scopes —, company from `X-Company`, groups for it.
219
+ */
216
220
  async authenticate(request) {
221
+ const key = await this.apiKeyOf(request);
222
+ if (key !== null)
223
+ return this.#identity(request, key.user, {
224
+ elevated: false,
225
+ scopes: key.scopes,
226
+ apiKey: key.id,
227
+ });
217
228
  const session = await this.session(request);
218
229
  if (session === null)
219
230
  return null;
220
231
  if (session.twoFactorRequired)
221
232
  throw unauthenticated('two-factor-required', 'Enter the code from your authenticator app first.', 'POST /api/v1/auth/2fa/verify.');
222
- const info = await this.#identityOf(session.user);
233
+ return this.#identity(request, session.user, { elevated: session.secondFactor });
234
+ }
235
+ async #identity(request, user, options) {
236
+ const info = await this.#identityOf(user);
223
237
  if (info === null)
224
238
  return null;
225
239
  const asked = Number(request.headers['x-company']);
@@ -229,8 +243,8 @@ export class AuthService {
229
243
  if (company === undefined)
230
244
  throw new HttpProblem(403, 'no-company', 'No company', 'This user is not allowed in any company.', 'An administrator assigns the companies of the user.');
231
245
  // Mandatory 2FA for administrators (RFC 0003 §5.2): their administrator groups count only in sessions that
232
- // entered a second factor, while 2FA is on. The user keeps every other group.
233
- const elevated = session.secondFactor && info.twoFactorEnabled;
246
+ // entered a second factor, while 2FA is on — never through an API key. The user keeps every other group.
247
+ const elevated = options.elevated && info.twoFactorEnabled;
234
248
  const groups = info.assignments
235
249
  .filter((a) => a.company === null || a.company === company)
236
250
  .map((a) => a.group)
@@ -241,15 +255,225 @@ export class AuthService {
241
255
  (g.expiresAt === null || g.expiresAt > now))
242
256
  .map((g) => g.grant);
243
257
  return {
244
- user: session.user,
258
+ user,
245
259
  company,
246
260
  companies: info.companies,
247
261
  groups: [...new Set(groups)],
248
262
  grants: [...new Set(grants)],
249
263
  lang: info.lang,
250
264
  tz: info.tz,
265
+ ...(options.scopes === undefined ? {} : { scopes: options.scopes }),
266
+ ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
251
267
  };
252
268
  }
269
+ /**
270
+ * The request's API key (RFC 0017 §5.1): `X-Api-Key: sk_…` or `Authorization: Bearer sk_…`; null without one.
271
+ * @throws {HttpProblem} 401 for an `X-Api-Key` that is unknown, revoked or expired
272
+ */
273
+ async apiKeyOf(request) {
274
+ const header = request.headers['x-api-key'];
275
+ const explicit = typeof header === 'string' && header !== '' ? header : undefined;
276
+ const bearer = /^Bearer\s+(sk_\S+)$/i.exec(String(request.headers.authorization ?? ''))?.[1];
277
+ const token = explicit ?? bearer;
278
+ if (token === undefined)
279
+ return null;
280
+ const tokenHash = digest(token);
281
+ const cached = this.#keyCache.get(tokenHash);
282
+ if (cached !== undefined && Date.now() - cached.at < this.#options.cacheMs)
283
+ return cached.key;
284
+ const key = await this.#system('api key', async (env) => {
285
+ const row = one(await fetch(model(env, 'auth.api_key').search({ where: { tokenHash, revoked: false } }), [
286
+ 'user',
287
+ 'scopes',
288
+ 'expiresAt',
289
+ 'lastUsedAt',
290
+ ]));
291
+ if (row === undefined)
292
+ return null;
293
+ const now = Date.now();
294
+ if ((timeOf(row['expiresAt']) ?? 0) <= now)
295
+ return null;
296
+ // Archived users' keys stop working with them.
297
+ if ((await model(env, 'res.users').search({ where: { id: row['user'] } })).length === 0)
298
+ return null;
299
+ if ((timeOf(row['lastUsedAt']) ?? 0) + 5 * 60_000 < now)
300
+ await model(env, 'auth.api_key')
301
+ .browse([row['id']])
302
+ .write({ lastUsedAt: new Date(now), lastUsedIp: request.ip });
303
+ return {
304
+ id: row['id'],
305
+ user: row['user'],
306
+ scopes: String(row['scopes'])
307
+ .split(' ')
308
+ .filter((x) => x !== ''),
309
+ };
310
+ });
311
+ if (key === null) {
312
+ this.#keyCache.delete(tokenHash);
313
+ // A bearer token that is no key may still be a session's.
314
+ if (explicit === undefined)
315
+ return null;
316
+ throw unauthenticated('invalid-api-key', 'The API key is unknown, revoked or expired.', 'Create a new key (Settings › API keys).');
317
+ }
318
+ this.#keyCache.set(tokenHash, { at: Date.now(), key });
319
+ return key;
320
+ }
321
+ /** May `actor` manage `user`'s keys: their own, or a service account's when the actor is an administrator. */
322
+ async #mayManageKeysOf(env, session, user) {
323
+ if (user === session.user)
324
+ return true;
325
+ const [target] = await fetch(model(env, 'res.users').search({ where: { id: user } }), [
326
+ 'isService',
327
+ ]);
328
+ return (target?.['isService'] === true &&
329
+ session.secondFactor &&
330
+ (await this.#holdsAdministrator(env, session.user)));
331
+ }
332
+ /**
333
+ * A new API key for `user` (the session's own, or a service account's for administrators), with `scopes` already
334
+ * checked against the installed apps. The secret is returned once.
335
+ */
336
+ async createApiKey(session, input, ip) {
337
+ const days = input.expiresInDays ?? 365;
338
+ if (!Number.isInteger(days) || days < 1 || days > 730)
339
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'expiresInDays is 1 to 730.');
340
+ const name = input.name.trim();
341
+ if (name === '' || name.length > 100)
342
+ throw new HttpProblem(400, 'invalid-body', 'Invalid body', 'A key has a name of up to 100 characters.');
343
+ const user = input.user ?? session.user;
344
+ return this.#system('create api key', async (env) => {
345
+ if (!(await this.#mayManageKeysOf(env, session, user)))
346
+ throw new HttpProblem(403, 'access', 'Forbidden', 'Keys are made for yourself, or by an administrator for a service account.');
347
+ const token = `sk_${randomToken(30)
348
+ .replace(/[^A-Za-z0-9]/g, '')
349
+ .slice(0, 40)}`;
350
+ const prefix = token.slice(0, 11);
351
+ const created = await model(env, 'auth.api_key').create([
352
+ {
353
+ name,
354
+ user,
355
+ tokenHash: digest(token),
356
+ prefix,
357
+ scopes: input.scopes.join(' '),
358
+ expiresAt: new Date(Date.now() + days * 86_400_000),
359
+ issuedBy: session.user,
360
+ },
361
+ ]);
362
+ const id = created.ids[0];
363
+ await audit(env, {
364
+ kind: 'auth.api_key_created',
365
+ summary: `API key "${name}" (${prefix}…) created for user ${user}: ${input.scopes.join(' ')}.`,
366
+ actor: session.user,
367
+ company: null,
368
+ details: { key: id, user, scopes: [...input.scopes], expiresInDays: days },
369
+ ...(ip === undefined ? {} : { ip }),
370
+ });
371
+ const [info] = await this.#keys(env, [id]);
372
+ return { ...info, token };
373
+ });
374
+ }
375
+ async #keys(env, ids, users) {
376
+ const where = ids !== undefined ? { id: { in: [...ids] } } : { user: { in: [...(users ?? [])] } };
377
+ const rows = await fetch(model(env, 'auth.api_key').search({ where, order: ['id desc'] }), [
378
+ 'name',
379
+ 'user',
380
+ 'prefix',
381
+ 'scopes',
382
+ 'expiresAt',
383
+ 'lastUsedAt',
384
+ 'lastUsedIp',
385
+ 'revoked',
386
+ 'createdAt',
387
+ ]);
388
+ const userIds = [...new Set(rows.map((r) => r['user']))];
389
+ const names = new Map((userIds.length === 0
390
+ ? []
391
+ : await fetch(model(env, 'res.users').search({ where: { id: { in: userIds } } }), ['name'])).map((u) => [u['id'], String(u['name'])]));
392
+ return rows.map((r) => ({
393
+ id: r['id'],
394
+ name: String(r['name']),
395
+ user: r['user'],
396
+ userName: names.get(r['user']) ?? '',
397
+ prefix: String(r['prefix']),
398
+ scopes: String(r['scopes'])
399
+ .split(' ')
400
+ .filter((x) => x !== ''),
401
+ expiresAt: new Date(timeOf(r['expiresAt']) ?? 0).toISOString(),
402
+ lastUsedAt: timeOf(r['lastUsedAt']) === null
403
+ ? null
404
+ : new Date(timeOf(r['lastUsedAt']) ?? 0).toISOString(),
405
+ lastUsedIp: typeof r['lastUsedIp'] === 'string' ? r['lastUsedIp'] : null,
406
+ revoked: r['revoked'] === true,
407
+ }));
408
+ }
409
+ /** The session user's keys, and every service account's for administrators. */
410
+ async listApiKeys(session) {
411
+ return this.#system('list api keys', async (env) => {
412
+ const users = [session.user];
413
+ if (session.secondFactor && (await this.#holdsAdministrator(env, session.user)))
414
+ users.push(...(await model(env, 'res.users').search({ where: { isService: true } })).ids);
415
+ return this.#keys(env, undefined, users);
416
+ });
417
+ }
418
+ /** Revokes a key at once (the session's own, or a service account's for administrators). */
419
+ async revokeApiKey(session, id, ip) {
420
+ await this.#system('revoke api key', async (env) => {
421
+ const [key] = await fetch(model(env, 'auth.api_key').search({ where: { id } }), [
422
+ 'user',
423
+ 'prefix',
424
+ 'tokenHash',
425
+ ]);
426
+ if (key === undefined || !(await this.#mayManageKeysOf(env, session, key['user'])))
427
+ throw new HttpProblem(404, 'missing-record', 'Not found', `There is no API key ${id} of yours.`);
428
+ await model(env, 'auth.api_key').browse([id]).write({ revoked: true });
429
+ this.#keyCache.delete(String(key['tokenHash']));
430
+ await audit(env, {
431
+ kind: 'auth.api_key_revoked',
432
+ summary: `API key ${String(key['prefix'])}… revoked.`,
433
+ actor: session.user,
434
+ company: null,
435
+ details: { key: id },
436
+ ...(ip === undefined ? {} : { ip }),
437
+ });
438
+ });
439
+ }
440
+ /**
441
+ * A service account (RFC 0017 §5.1): a user without a password who acts only through API keys, with `groups`
442
+ * (checked by the caller) in the administrator's companies. Administrators only.
443
+ */
444
+ async createServiceAccount(session, input, ip) {
445
+ return this.#system(`create service account ${input.login}`, async (env) => {
446
+ if (!(session.secondFactor && (await this.#holdsAdministrator(env, session.user))))
447
+ throw new HttpProblem(403, 'access', 'Forbidden', 'Service accounts are created by administrators.');
448
+ const administrators = new Set(env.access.administratorGroups());
449
+ if (input.groups.some((g) => administrators.has(g)))
450
+ throw new HttpProblem(422, 'invalid-groups', 'Invalid roles', 'A service account cannot be an administrator.');
451
+ const [me] = await fetch(model(env, 'res.users').search({ where: { id: session.user } }), [
452
+ 'company',
453
+ ]);
454
+ const company = typeof me?.['company'] === 'number' ? me['company'] : undefined;
455
+ const created = await model(env, 'res.users').create([
456
+ {
457
+ login: input.login,
458
+ name: input.name,
459
+ isService: true,
460
+ ...(company === undefined ? {} : { company, companies: { set: [company] } }),
461
+ },
462
+ ]);
463
+ const id = created.ids[0];
464
+ if (input.groups.length > 0)
465
+ await model(env, 'auth.user_group').create(input.groups.map((group) => ({ user: id, group })));
466
+ await audit(env, {
467
+ kind: 'auth.service_account_created',
468
+ summary: `Service account ${input.login} created with ${input.groups.join(', ') || 'no roles'}.`,
469
+ actor: session.user,
470
+ company: null,
471
+ details: { user: id, groups: [...input.groups] },
472
+ ...(ip === undefined ? {} : { ip }),
473
+ });
474
+ return { id, login: input.login, name: input.name, groups: [...input.groups] };
475
+ });
476
+ }
253
477
  /** Identity loader for Shutro (`createShutro({ identity })`): the user is active; companies and groups from the database. */
254
478
  async verify(uid) {
255
479
  const info = await this.#identityOf(uid);
@@ -8,7 +8,7 @@ import type { KoshModule } from '../modules-sdk/index.js';
8
8
  import type { AuthService } from './auth/service.js';
9
9
  import type { Driver, Shutro } from './orm.js';
10
10
  /** The demo users' password (every demo user; printed by `kosh init --demo`). */
11
- export declare const DEMO_PASSWORD = "kosh demo 2026";
11
+ export declare const DEMO_PASSWORD = "shutro demo 2026";
12
12
  /** What one module's seeder did. */
13
13
  export interface DemoResult {
14
14
  readonly module: string;
@@ -1,6 +1,6 @@
1
1
  import { APPS_TABLE, appTableStatements } from './apps.js';
2
2
  /** The demo users' password (every demo user; printed by `kosh init --demo`). */
3
- export const DEMO_PASSWORD = 'kosh demo 2026';
3
+ export const DEMO_PASSWORD = 'shutro demo 2026';
4
4
  const model = (env, name) => env.model(name);
5
5
  /** Loads the demo data of the modules that have none yet. Returns a line per module that seeded something. */
6
6
  export async function installDemo(options) {
@@ -0,0 +1,2 @@
1
+ /** Copies `SHUTRO_ERP_*` variables to their `KOSH_*` names in `env` (the process's by default); returns `env`. */
2
+ export declare function applyEnvironmentNames(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The environment's names: installations set `SHUTRO_ERP_*` (the product's name, as in the docs and new projects);
3
+ * the code reads the older `KOSH_*` names, which keep working. Applied once where a process starts (the server, the
4
+ * CLI): each `SHUTRO_ERP_X` that is set fills `KOSH_X` unless that one is set too.
5
+ */
6
+ const PREFIX = 'SHUTRO_ERP_';
7
+ /** Copies `SHUTRO_ERP_*` variables to their `KOSH_*` names in `env` (the process's by default); returns `env`. */
8
+ export function applyEnvironmentNames(env = process.env) {
9
+ for (const [name, value] of Object.entries(env))
10
+ if (name.startsWith(PREFIX) && value !== undefined) {
11
+ const old = `KOSH_${name.slice(PREFIX.length)}`;
12
+ if (env[old] === undefined)
13
+ env[old] = value;
14
+ }
15
+ return env;
16
+ }