@aderis-erp/cli 1.0.15 → 1.1.0

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 (182) hide show
  1. package/README.md +30 -8
  2. package/dist/commands/access-groups.d.ts +0 -1
  3. package/dist/commands/access-groups.js +54 -78
  4. package/dist/commands/account-transaction-allocations.d.ts +0 -1
  5. package/dist/commands/account-transaction-allocations.js +51 -75
  6. package/dist/commands/account-transactions.d.ts +0 -1
  7. package/dist/commands/account-transactions.js +80 -94
  8. package/dist/commands/accounts.d.ts +0 -1
  9. package/dist/commands/accounts.js +59 -83
  10. package/dist/commands/agent-platform.d.ts +0 -1
  11. package/dist/commands/agent-platform.js +58 -82
  12. package/dist/commands/agent-runs.d.ts +39 -0
  13. package/dist/commands/agent-runs.js +1155 -0
  14. package/dist/commands/analytics.d.ts +0 -1
  15. package/dist/commands/analytics.js +52 -76
  16. package/dist/commands/{text-templates.d.ts → api-keys.d.ts} +1 -2
  17. package/dist/commands/api-keys.js +1150 -0
  18. package/dist/commands/async-jobs.d.ts +39 -0
  19. package/dist/commands/async-jobs.js +1158 -0
  20. package/dist/commands/audit-log.d.ts +0 -1
  21. package/dist/commands/audit-log.js +51 -75
  22. package/dist/commands/auth/login.d.ts +9 -0
  23. package/dist/commands/auth/login.js +73 -20
  24. package/dist/commands/auth/logout.d.ts +4 -0
  25. package/dist/commands/auth/logout.js +4 -0
  26. package/dist/commands/auth/status.js +26 -17
  27. package/dist/commands/auth/tenant.js +3 -6
  28. package/dist/commands/auth.d.ts +0 -1
  29. package/dist/commands/auth.js +5 -34
  30. package/dist/commands/bank-accounts.d.ts +0 -1
  31. package/dist/commands/bank-accounts.js +68 -91
  32. package/dist/commands/bank-integrations.d.ts +1 -1
  33. package/dist/commands/bank-integrations.js +71 -92
  34. package/dist/commands/business-partners.d.ts +1 -1
  35. package/dist/commands/business-partners.js +84 -100
  36. package/dist/commands/chat.d.ts +0 -1
  37. package/dist/commands/chat.js +57 -80
  38. package/dist/commands/config/add.d.ts +3 -0
  39. package/dist/commands/config/add.js +34 -16
  40. package/dist/commands/config/current.js +3 -0
  41. package/dist/commands/config/list.js +3 -0
  42. package/dist/commands/credit-card-accounts.d.ts +1 -1
  43. package/dist/commands/credit-card-accounts.js +72 -90
  44. package/dist/commands/delivery-notes.d.ts +0 -1
  45. package/dist/commands/delivery-notes.js +83 -105
  46. package/dist/commands/dunning-configs.d.ts +0 -1
  47. package/dist/commands/dunning-configs.js +54 -78
  48. package/dist/commands/dunning-reminders.d.ts +0 -1
  49. package/dist/commands/dunning-reminders.js +57 -81
  50. package/dist/commands/dunning-runs.d.ts +0 -1
  51. package/dist/commands/dunning-runs.js +50 -74
  52. package/dist/commands/email-transactions.d.ts +0 -1
  53. package/dist/commands/email-transactions.js +52 -76
  54. package/dist/commands/employees.d.ts +0 -1
  55. package/dist/commands/employees.js +81 -105
  56. package/dist/commands/entity-configurations.d.ts +0 -1
  57. package/dist/commands/entity-configurations.js +52 -76
  58. package/dist/commands/event-processor-mappings.d.ts +0 -1
  59. package/dist/commands/event-processor-mappings.js +54 -78
  60. package/dist/commands/files.d.ts +0 -1
  61. package/dist/commands/files.js +61 -85
  62. package/dist/commands/health.d.ts +0 -1
  63. package/dist/commands/health.js +54 -78
  64. package/dist/commands/imports.d.ts +39 -0
  65. package/dist/commands/imports.js +1151 -0
  66. package/dist/commands/inbox.d.ts +0 -1
  67. package/dist/commands/inbox.js +57 -80
  68. package/dist/commands/integration-identities.d.ts +40 -0
  69. package/dist/commands/integration-identities.js +1151 -0
  70. package/dist/commands/integrations.d.ts +0 -1
  71. package/dist/commands/integrations.js +52 -76
  72. package/dist/commands/item-categories.d.ts +0 -1
  73. package/dist/commands/item-categories.js +54 -78
  74. package/dist/commands/items.d.ts +0 -1
  75. package/dist/commands/items.js +54 -78
  76. package/dist/commands/journal-lines.d.ts +0 -1
  77. package/dist/commands/journal-lines.js +50 -74
  78. package/dist/commands/journals.d.ts +1 -1
  79. package/dist/commands/journals.js +75 -96
  80. package/dist/commands/ledgers.d.ts +3 -2
  81. package/dist/commands/ledgers.js +100 -95
  82. package/dist/commands/my-account.d.ts +39 -0
  83. package/dist/commands/my-account.js +1160 -0
  84. package/dist/commands/office-locations.d.ts +0 -1
  85. package/dist/commands/office-locations.js +54 -78
  86. package/dist/commands/payment-methods.d.ts +0 -1
  87. package/dist/commands/payment-methods.js +59 -83
  88. package/dist/commands/payment-runs.d.ts +0 -1
  89. package/dist/commands/payment-runs.js +66 -90
  90. package/dist/commands/payment-terms.d.ts +0 -1
  91. package/dist/commands/payment-terms.js +55 -79
  92. package/dist/commands/payments.d.ts +0 -1
  93. package/dist/commands/payments.js +56 -80
  94. package/dist/commands/payroll-runs.d.ts +43 -0
  95. package/dist/commands/payroll-runs.js +1188 -0
  96. package/dist/commands/permissions.d.ts +39 -0
  97. package/dist/commands/permissions.js +1146 -0
  98. package/dist/commands/price-lists.d.ts +0 -1
  99. package/dist/commands/price-lists.js +59 -83
  100. package/dist/commands/processor-mappings.d.ts +0 -1
  101. package/dist/commands/processor-mappings.js +53 -77
  102. package/dist/commands/processors.d.ts +0 -1
  103. package/dist/commands/processors.js +60 -85
  104. package/dist/commands/purchase-order-types.d.ts +0 -1
  105. package/dist/commands/purchase-order-types.js +54 -78
  106. package/dist/commands/purchase-orders.d.ts +0 -1
  107. package/dist/commands/purchase-orders.js +74 -97
  108. package/dist/commands/reports.d.ts +0 -1
  109. package/dist/commands/reports.js +54 -78
  110. package/dist/commands/roles.d.ts +39 -0
  111. package/dist/commands/{text-templates.js → roles.js} +64 -90
  112. package/dist/commands/salary-types.d.ts +0 -1
  113. package/dist/commands/salary-types.js +54 -78
  114. package/dist/commands/sales-invoices.d.ts +0 -1
  115. package/dist/commands/sales-invoices.js +90 -110
  116. package/dist/commands/sales-orders.d.ts +0 -1
  117. package/dist/commands/sales-orders.js +83 -105
  118. package/dist/commands/saved-views.d.ts +0 -1
  119. package/dist/commands/saved-views.js +52 -76
  120. package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
  121. package/dist/commands/scheduled-processor-mappings.js +54 -78
  122. package/dist/commands/schedules.d.ts +0 -1
  123. package/dist/commands/schedules.js +58 -82
  124. package/dist/commands/switch.js +4 -4
  125. package/dist/commands/tag-configurations.d.ts +0 -1
  126. package/dist/commands/tag-configurations.js +56 -79
  127. package/dist/commands/tags.d.ts +0 -1
  128. package/dist/commands/tags.js +54 -78
  129. package/dist/commands/tax-codes.d.ts +0 -1
  130. package/dist/commands/tax-codes.js +57 -81
  131. package/dist/commands/tax-matrix.d.ts +0 -1
  132. package/dist/commands/tax-matrix.js +54 -78
  133. package/dist/commands/templates.d.ts +39 -0
  134. package/dist/commands/templates.js +1157 -0
  135. package/dist/commands/tenants.d.ts +0 -1
  136. package/dist/commands/tenants.js +58 -81
  137. package/dist/commands/tools.d.ts +0 -1
  138. package/dist/commands/tools.js +51 -75
  139. package/dist/commands/users.d.ts +42 -0
  140. package/dist/commands/users.js +1174 -0
  141. package/dist/commands/vat-declarations.d.ts +3 -1
  142. package/dist/commands/vat-declarations.js +78 -93
  143. package/dist/commands/vendor-invoices.d.ts +0 -1
  144. package/dist/commands/vendor-invoices.js +87 -110
  145. package/dist/commands/webhooks.d.ts +0 -1
  146. package/dist/commands/webhooks.js +56 -80
  147. package/dist/commands/workflows.d.ts +0 -1
  148. package/dist/commands/workflows.js +52 -76
  149. package/dist/lib/api-client.d.ts +6 -0
  150. package/dist/lib/api-client.js +16 -6
  151. package/dist/lib/auth/constants.d.ts +11 -0
  152. package/dist/lib/auth/constants.js +11 -0
  153. package/dist/lib/auth/errors.d.ts +17 -0
  154. package/dist/lib/auth/errors.js +22 -0
  155. package/dist/lib/auth/flow.d.ts +27 -0
  156. package/dist/lib/auth/flow.js +163 -0
  157. package/dist/lib/auth/guards.d.ts +5 -0
  158. package/dist/lib/auth/guards.js +14 -0
  159. package/dist/lib/auth/http.d.ts +16 -0
  160. package/dist/lib/auth/http.js +49 -0
  161. package/dist/lib/auth/index.d.ts +49 -0
  162. package/dist/lib/auth/index.js +228 -0
  163. package/dist/lib/auth/jwks.d.ts +19 -0
  164. package/dist/lib/auth/jwks.js +112 -0
  165. package/dist/lib/auth/keycloak-provider.d.ts +91 -0
  166. package/dist/lib/auth/keycloak-provider.js +244 -0
  167. package/dist/lib/auth/pages.d.ts +25 -0
  168. package/dist/lib/auth/pages.js +123 -0
  169. package/dist/lib/auth/pkce.d.ts +11 -0
  170. package/dist/lib/auth/pkce.js +17 -0
  171. package/dist/lib/auth/tokens.d.ts +59 -0
  172. package/dist/lib/auth/tokens.js +105 -0
  173. package/dist/lib/auth/types.d.ts +61 -0
  174. package/dist/lib/auth/types.js +1 -0
  175. package/dist/lib/auth/validated-url.d.ts +6 -0
  176. package/dist/lib/auth/validated-url.js +53 -0
  177. package/dist/lib/config.d.ts +13 -3
  178. package/dist/lib/config.js +24 -19
  179. package/oclif.manifest.json +2447 -641
  180. package/package.json +5 -4
  181. package/dist/lib/auth.d.ts +0 -43
  182. package/dist/lib/auth.js +0 -610
@@ -1,5 +1,5 @@
1
1
  import { Command, Args, Flags } from '@oclif/core';
2
- import { apiRequest, ApiError, normalizeSortBy } from '../lib/api-client.js';
2
+ import { apiRequest, ApiError, isTextualMediaType, normalizeSortBy } from '../lib/api-client.js';
3
3
  import { getCurrentContext, getContext, getBaseUrls, hasCredentials, missingCredentialsMessage } from '../lib/config.js';
4
4
  // Add 'import' action if both create and update exist
5
5
  const HAS_IMPORT = false;
@@ -8,9 +8,9 @@ const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = [];
9
9
  const SUBCOMMANDS = [...["list", "status", "find"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
- 'list': { method: 'GET', path: '/v1/workflows', summary: 'Get all workflows with their summary', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
12
- 'status': { method: 'POST', path: '/v1/workflows/status', summary: 'Create a new workflow status update', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
13
- 'find': { method: 'GET', path: '/v1/workflows/{workflowId}', summary: 'Get workflow details including status updates and entity changes', pathParams: ['workflowId'], hasBody: false, isMultipart: false, isBinaryResponse: false, bodyArrayFields: [] },
11
+ 'list': { method: 'GET', path: '/v1/workflows', summary: 'Get all workflows with their summary', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
12
+ 'status': { method: 'POST', path: '/v1/workflows/status', summary: 'Create a new workflow status update', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
13
+ 'find': { method: 'GET', path: '/v1/workflows/{workflowId}', summary: 'Get workflow details including status updates and entity changes', pathParams: ['workflowId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
14
14
  };
15
15
  export default class Workflows extends Command {
16
16
  static description = 'Manage workflows';
@@ -37,7 +37,7 @@ export default class Workflows extends Command {
37
37
  'sort-by': Flags.string({ description: 'Sort field(s), comma-separated. Direction (:ASC/:DESC) is optional; date-like fields default to DESC, others to ASC.' }),
38
38
  filter: Flags.string({ description: 'Filter (e.g. --filter status=ACTIVE). Operators: in:A,B, like:%, gt:, lt:, null:', multiple: true }),
39
39
  query: Flags.string({ description: 'Additional query params as JSON' }),
40
- fields: Flags.string({ description: 'Comma-separated list of fields to show (e.g. --fields=id,name,status)' }),
40
+ fields: Flags.string({ description: 'Comma-separated list of fields to return, applied by the API (e.g. --fields=id,name,status)' }),
41
41
  sanitize: Flags.boolean({ description: 'Remove null values, audit fields, and duplicate UUIDs from output' }),
42
42
  'show-http-request': Flags.boolean({ description: 'Show HTTP request details (method, URL, body)' }),
43
43
  };
@@ -109,6 +109,8 @@ export default class Workflows extends Command {
109
109
  queryParams.search = flags.search;
110
110
  if (flags['sort-by'])
111
111
  queryParams.sortBy = normalizeSortBy(flags['sort-by']);
112
+ if (flags.fields)
113
+ queryParams.fields = flags.fields;
112
114
  if (flags.query) {
113
115
  try {
114
116
  Object.assign(queryParams, JSON.parse(flags.query));
@@ -256,10 +258,11 @@ export default class Workflows extends Command {
256
258
  process.stderr.write('\n');
257
259
  }
258
260
  try {
259
- // Binary response: download to file
261
+ // Binary response: to -f / --file, or to stdout when it is redirected.
260
262
  if (endpoint.isBinaryResponse) {
261
- if (!flags.file) {
262
- this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx).`);
263
+ const toStdout = !flags.file;
264
+ if (toStdout && process.stdout.isTTY) {
265
+ this.error(`Endpoint returns a binary file. Specify the output path with -f / --file (e.g. -f report.xlsx), or redirect stdout to a file.`);
263
266
  }
264
267
  const binResponse = await apiRequest(endpoint.method, apiPath, {
265
268
  params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
@@ -267,24 +270,55 @@ export default class Workflows extends Command {
267
270
  contextName: flags.context,
268
271
  binary: true,
269
272
  });
270
- const fsModule = await import('node:fs');
271
273
  const binData = binResponse.data;
272
274
  const buffer = Buffer.isBuffer(binData)
273
275
  ? binData
274
276
  : Buffer.from(binData);
277
+ if (toStdout) {
278
+ // Nothing else may reach stdout here — it is the file.
279
+ process.stdout.write(buffer);
280
+ return;
281
+ }
282
+ const fsModule = await import('node:fs');
275
283
  fsModule.writeFileSync(flags.file, buffer);
276
284
  this.log(`Saved ${buffer.length} bytes to ${flags.file}`);
277
285
  return;
278
286
  }
279
- const response = await apiRequest(endpoint.method, apiPath, {
280
- params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
281
- data,
282
- contextName: flags.context,
283
- acceptAny: true,
284
- });
287
+ let response;
288
+ if (endpoint.hasBinaryVariant) {
289
+ // This route answers JSON or a file depending on the request body, so the
290
+ // bytes are read first and the response's own content-type decides. Asking
291
+ // for text here would corrupt an xlsx/pdf before anything could inspect it.
292
+ const raw = await apiRequest(endpoint.method, apiPath, {
293
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
294
+ data,
295
+ contextName: flags.context,
296
+ binary: true,
297
+ });
298
+ const rawType = String(raw.headers['content-type'] || '').toLowerCase();
299
+ const rawData = raw.data;
300
+ const buffer = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData);
301
+ if (!isTextualMediaType(rawType)) {
302
+ if (process.stdout.isTTY) {
303
+ this.error(`The API returned ${rawType || 'binary data'}. Redirect stdout to a file, e.g. ... > report.xlsx`);
304
+ }
305
+ // Nothing else may reach stdout here — it is the file.
306
+ process.stdout.write(buffer);
307
+ return;
308
+ }
309
+ response = { data: buffer.toString('utf8'), headers: raw.headers, status: raw.status };
310
+ }
311
+ else {
312
+ response = await apiRequest(endpoint.method, apiPath, {
313
+ params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
314
+ data,
315
+ contextName: flags.context,
316
+ acceptAny: true,
317
+ });
318
+ }
285
319
  // Check content-type for non-JSON responses (XML, text, etc.)
286
320
  const contentType = (response.headers['content-type'] || '').toLowerCase();
287
- if (contentType.includes('xml') || contentType.includes('text/')) {
321
+ if (isTextualMediaType(contentType) && !contentType.startsWith('application/json')) {
288
322
  // Output raw content for XML/text responses (CSV, plain, HTML, etc.)
289
323
  this.log(String(response.data));
290
324
  return;
@@ -311,11 +345,6 @@ export default class Workflows extends Command {
311
345
  output = obj[arrayKeys[0]];
312
346
  }
313
347
  }
314
- // Filter fields if --fields flag is provided
315
- if (flags.fields) {
316
- const fieldList = flags.fields.split(',').map(f => f.trim());
317
- output = this.filterFields(output, fieldList);
318
- }
319
348
  // Sanitize output if requested
320
349
  if (flags.sanitize) {
321
350
  output = this.sanitizeOutput(output);
@@ -570,6 +599,8 @@ export default class Workflows extends Command {
570
599
  baseParams.sortBy = normalizeSortBy(flags['sort-by']);
571
600
  if (flags.include)
572
601
  baseParams.include = flags.include;
602
+ if (flags.fields)
603
+ baseParams.fields = flags.fields;
573
604
  if (flags.query) {
574
605
  try {
575
606
  Object.assign(baseParams, JSON.parse(flags.query));
@@ -624,12 +655,7 @@ export default class Workflows extends Command {
624
655
  }
625
656
  }
626
657
  process.stderr.write(' done\n');
627
- // Filter fields if specified
628
658
  let output = allItems;
629
- if (flags.fields) {
630
- const fieldList = flags.fields.split(',').map(f => f.trim());
631
- output = this.filterFields(allItems, fieldList);
632
- }
633
659
  // Sanitize output if requested
634
660
  if (flags.sanitize) {
635
661
  output = this.sanitizeOutput(output);
@@ -958,56 +984,6 @@ export default class Workflows extends Command {
958
984
  return `\$${op}`;
959
985
  return opRe.test(rest) ? `\$${op}:${this.parseFilterValue(rest)}` : `\$${op}:${rest}`;
960
986
  }
961
- filterFields(data, fields) {
962
- const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
963
- const pick = (obj, keys) => {
964
- // Group keys by their first segment so sibling sub-paths
965
- // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
966
- // and don't clobber each other on merge.
967
- const directKeys = new Set();
968
- const nestedKeys = new Map();
969
- for (const key of keys) {
970
- if (key.split('.').some(isUnsafeKey))
971
- continue;
972
- if (key.includes('.')) {
973
- const [first, ...rest] = key.split('.');
974
- if (isUnsafeKey(first))
975
- continue;
976
- const list = nestedKeys.get(first);
977
- if (list) {
978
- list.push(rest.join('.'));
979
- }
980
- else {
981
- nestedKeys.set(first, [rest.join('.')]);
982
- }
983
- }
984
- else {
985
- directKeys.add(key);
986
- }
987
- }
988
- const result = {};
989
- for (const key of directKeys) {
990
- if (key in obj)
991
- result[key] = obj[key];
992
- }
993
- for (const [first, restKeys] of nestedKeys) {
994
- const val = obj[first];
995
- if (!val || typeof val !== 'object')
996
- continue;
997
- if (Array.isArray(val)) {
998
- result[first] = val.map(item => pick(item, restKeys));
999
- }
1000
- else {
1001
- result[first] = pick(val, restKeys);
1002
- }
1003
- }
1004
- return result;
1005
- };
1006
- if (Array.isArray(data)) {
1007
- return data.map(item => pick(item, fields));
1008
- }
1009
- return pick(data, fields);
1010
- }
1011
987
  sanitizeOutput(data) {
1012
988
  const auditFields = ['_type', 'tenantId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'];
1013
989
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -28,6 +28,12 @@ export interface PaginatedResponse<T> {
28
28
  * into repeated `sortBy=a:ASC&sortBy=b:DESC` params.
29
29
  */
30
30
  export declare function normalizeSortBy(input: string): string[];
31
+ /**
32
+ * Whether a response body is text the CLI may decode. Matched on the media type
33
+ * rather than by substring: application/vnd.openxmlformats-officedocument...
34
+ * (xlsx) contains "xml" but is a zip, and decoding it corrupts every byte.
35
+ */
36
+ export declare function isTextualMediaType(contentType: string): boolean;
31
37
  export declare class ApiError extends Error {
32
38
  status: number;
33
39
  data?: unknown | undefined;
@@ -1,6 +1,6 @@
1
1
  import axios from 'axios';
2
- import { getAuthHeaders } from './auth.js';
3
- import { getAuthType, getBaseUrls, getCurrentContext } from './config.js';
2
+ import { getAuthHeaders } from './auth/index.js';
3
+ import { getBaseUrls, getCurrentContext, unauthorizedMessage } from './config.js';
4
4
  /**
5
5
  * Normalize a --sort-by value: comma-separated fields, each optionally suffixed
6
6
  * with `:ASC` or `:DESC`. Fields without an explicit direction default to DESC
@@ -21,6 +21,19 @@ export function normalizeSortBy(input) {
21
21
  return `${part}:${dateLike ? 'DESC' : 'ASC'}`;
22
22
  });
23
23
  }
24
+ /**
25
+ * Whether a response body is text the CLI may decode. Matched on the media type
26
+ * rather than by substring: application/vnd.openxmlformats-officedocument...
27
+ * (xlsx) contains "xml" but is a zip, and decoding it corrupts every byte.
28
+ */
29
+ export function isTextualMediaType(contentType) {
30
+ const type = contentType.split(';')[0].trim().toLowerCase();
31
+ if (type.startsWith('text/'))
32
+ return true;
33
+ if (type === 'application/json' || type === 'application/xml')
34
+ return true;
35
+ return type.endsWith('+json') || type.endsWith('+xml');
36
+ }
24
37
  export class ApiError extends Error {
25
38
  status;
26
39
  data;
@@ -60,10 +73,7 @@ export async function createApiClient(contextName, options = false) {
60
73
  // Handle 401 with a clear message, pointing at the right remedy for the
61
74
  // context's auth type — re-login only helps OAuth contexts.
62
75
  if (error.response.status === 401) {
63
- const message = getAuthType(context) === 'api_key'
64
- ? `API key for context "${context.name}" was rejected. Check the key with: aderis config set-api-key --context ${context.name}`
65
- : 'Session expired. Please run: aderis auth login';
66
- throw new ApiError(message, 401, error.response.data);
76
+ throw new ApiError(unauthorizedMessage(context), 401, error.response.data);
67
77
  }
68
78
  const message = extractErrorMessage(error.response.data);
69
79
  throw new ApiError(message, error.response.status, error.response.data);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Loopback redirect (RFC 8252 §7.3). The Keycloak client registers
3
+ * `http://localhost:*`; one shared port keeps every context on a registered redirect.
4
+ */
5
+ export declare const CALLBACK_PORT = 8085;
6
+ export declare const REDIRECT_URI = "http://localhost:8085/oauth/callback";
7
+ /** Headers for `application/x-www-form-urlencoded` token endpoint requests (RFC 6749 §4.1.3). */
8
+ export declare const FORM_POST_HEADERS: {
9
+ readonly Accept: "application/json";
10
+ readonly 'Content-Type': "application/x-www-form-urlencoded";
11
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Loopback redirect (RFC 8252 §7.3). The Keycloak client registers
3
+ * `http://localhost:*`; one shared port keeps every context on a registered redirect.
4
+ */
5
+ export const CALLBACK_PORT = 8085;
6
+ export const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth/callback`;
7
+ /** Headers for `application/x-www-form-urlencoded` token endpoint requests (RFC 6749 §4.1.3). */
8
+ export const FORM_POST_HEADERS = {
9
+ Accept: 'application/json',
10
+ 'Content-Type': 'application/x-www-form-urlencoded',
11
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * An authentication-protocol failure reported to the user, distinct from
3
+ * transport errors (axios) and from `ApiError`. Never wraps the axios error:
4
+ * its request config carries the form body (refresh token, PKCE verifier,
5
+ * client secret).
6
+ */
7
+ export declare class AuthError extends Error {
8
+ /** HTTP status of the response that caused this error, when there was one. */
9
+ readonly status?: number;
10
+ constructor(message: string, options?: {
11
+ status?: number;
12
+ });
13
+ }
14
+ /** The token that ended a tenant switch is not for the requested tenant; shown as "Tenant Switch Failed" (HTTP 400). */
15
+ export declare class TenantMismatchError extends AuthError {
16
+ constructor(message: string);
17
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * An authentication-protocol failure reported to the user, distinct from
3
+ * transport errors (axios) and from `ApiError`. Never wraps the axios error:
4
+ * its request config carries the form body (refresh token, PKCE verifier,
5
+ * client secret).
6
+ */
7
+ export class AuthError extends Error {
8
+ /** HTTP status of the response that caused this error, when there was one. */
9
+ status;
10
+ constructor(message, options = {}) {
11
+ super(message);
12
+ this.name = 'AuthError';
13
+ this.status = options.status;
14
+ }
15
+ }
16
+ /** The token that ended a tenant switch is not for the requested tenant; shown as "Tenant Switch Failed" (HTTP 400). */
17
+ export class TenantMismatchError extends AuthError {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'TenantMismatchError';
21
+ }
22
+ }
@@ -0,0 +1,27 @@
1
+ import { type Context, type TokenData } from '../config.js';
2
+ import { type PageMessage } from './pages.js';
3
+ import { type AuthProvider, type AuthRound } from './types.js';
4
+ export interface BrowserFlowOptions {
5
+ /** Line logger for the "Opening browser…" hints. Defaults to `console.log`. */
6
+ log?: (line: string) => void;
7
+ /** Opens the authorize URL. Defaults to the system browser; tests inject a stub. */
8
+ openBrowser?: (url: string) => Promise<unknown>;
9
+ /** Page shown after the last round succeeds. */
10
+ success: {
11
+ message: PageMessage | string;
12
+ title: string;
13
+ };
14
+ }
15
+ /** The injectable parts of a browser flow. */
16
+ export type FlowHooks = Pick<BrowserFlowOptions, 'log' | 'openBrowser'>;
17
+ /**
18
+ * Runs one or more authorization rounds through a single local callback server
19
+ * and a single browser tab. Per round: the browser lands on `/oauth/callback`,
20
+ * the code is exchanged via the provider, the tokens are saved to the context
21
+ * and the round's `afterExchange` hook runs (tokens it returns are saved and
22
+ * become the round's result). A following round is entered via
23
+ * a meta-refresh into its authorize URL; the last round answers the success
24
+ * page and resolves with its tokens. A callback whose `state` is not the
25
+ * expected round's is rejected.
26
+ */
27
+ export declare function runBrowserFlow(context: Context, provider: AuthProvider, rounds: AuthRound[], options: BrowserFlowOptions): Promise<TokenData>;
@@ -0,0 +1,163 @@
1
+ import * as http from 'node:http';
2
+ import { URL } from 'node:url';
3
+ import open from 'open';
4
+ import { saveTokens } from '../config.js';
5
+ import { CALLBACK_PORT } from './constants.js';
6
+ import { TenantMismatchError } from './errors.js';
7
+ import { renderAuthPage, renderRedirectPage } from './pages.js';
8
+ import { generateNonce, generatePkce, generateState } from './pkce.js';
9
+ const FLOW_TIMEOUT_MS = 5 * 60 * 1000;
10
+ async function prepareRounds(context, provider, rounds) {
11
+ const prepared = [];
12
+ for (const round of rounds) {
13
+ const pkce = generatePkce();
14
+ const state = generateState();
15
+ const nonce = generateNonce();
16
+ const authorizeUrl = await provider.buildAuthorizeUrl(context, {
17
+ codeChallenge: pkce.challenge,
18
+ nonce,
19
+ prompt: round.prompt,
20
+ state,
21
+ });
22
+ prepared.push({ ...round, authorizeUrl, codeVerifier: pkce.verifier, nonce, state });
23
+ }
24
+ return prepared;
25
+ }
26
+ /**
27
+ * Runs one or more authorization rounds through a single local callback server
28
+ * and a single browser tab. Per round: the browser lands on `/oauth/callback`,
29
+ * the code is exchanged via the provider, the tokens are saved to the context
30
+ * and the round's `afterExchange` hook runs (tokens it returns are saved and
31
+ * become the round's result). A following round is entered via
32
+ * a meta-refresh into its authorize URL; the last round answers the success
33
+ * page and resolves with its tokens. A callback whose `state` is not the
34
+ * expected round's is rejected.
35
+ */
36
+ export async function runBrowserFlow(context, provider, rounds, options) {
37
+ if (rounds.length === 0) {
38
+ throw new Error('A browser flow needs at least one authorization round');
39
+ }
40
+ const log = options.log ?? console.log;
41
+ const openBrowser = options.openBrowser ?? ((url) => open(url));
42
+ const prepared = await prepareRounds(context, provider, rounds);
43
+ return new Promise((resolve, reject) => {
44
+ const connections = new Set();
45
+ let currentRound = 0;
46
+ // States whose callback has been taken: a duplicate arriving while its
47
+ // exchange is in flight is answered, not redeemed again.
48
+ const redeemed = new Set();
49
+ // Holder: closeServer references the timer before it exists.
50
+ const timeout = {};
51
+ const closeServer = () => {
52
+ if (timeout.id)
53
+ clearTimeout(timeout.id);
54
+ for (const socket of connections) {
55
+ socket.destroy();
56
+ }
57
+ connections.clear();
58
+ server.close();
59
+ process.off('SIGINT', onSigint);
60
+ };
61
+ const onSigint = () => {
62
+ closeServer();
63
+ // Exit with the SIGINT status so the parent shell sees the termination.
64
+ // eslint-disable-next-line unicorn/no-process-exit, n/no-process-exit
65
+ process.exit(130);
66
+ };
67
+ const fail = (res, status, title, error) => {
68
+ res.writeHead(status, { 'Content-Type': 'text/html' });
69
+ res.end(renderAuthPage('error', title, error.message));
70
+ // Let the page reach the browser before the connection is torn down.
71
+ setImmediate(() => {
72
+ closeServer();
73
+ reject(error);
74
+ });
75
+ };
76
+ const server = http.createServer(async (req, res) => {
77
+ const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
78
+ if (url.pathname !== '/oauth/callback') {
79
+ res.writeHead(404);
80
+ res.end('Not Found');
81
+ return;
82
+ }
83
+ const code = url.searchParams.get('code');
84
+ const returnedState = url.searchParams.get('state');
85
+ const error = url.searchParams.get('error');
86
+ const errorDescription = url.searchParams.get('error_description');
87
+ if (error) {
88
+ fail(res, 400, 'Authentication Failed', new Error(errorDescription || error));
89
+ return;
90
+ }
91
+ // A callback already taken (a browser retry, or a late one after the last
92
+ // round) has nothing to redeem and must not abort the flow.
93
+ const round = prepared[currentRound];
94
+ if (!round || (returnedState && redeemed.has(returnedState))) {
95
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
96
+ res.end('This authorization response has already been used');
97
+ return;
98
+ }
99
+ if (!code || returnedState !== round.state) {
100
+ fail(res, 400, 'Authentication Failed', new Error('Invalid authorization response'));
101
+ return;
102
+ }
103
+ redeemed.add(round.state);
104
+ try {
105
+ const exchanged = await provider.exchangeCode(context, {
106
+ code,
107
+ codeVerifier: round.codeVerifier,
108
+ nonce: round.nonce,
109
+ });
110
+ saveTokens(context.name, exchanged);
111
+ const tokens = (await round.afterExchange?.(exchanged)) ?? exchanged;
112
+ if (tokens !== exchanged)
113
+ saveTokens(context.name, tokens);
114
+ currentRound += 1;
115
+ const next = prepared[currentRound];
116
+ if (next) {
117
+ res.writeHead(200, { 'Content-Type': 'text/html' });
118
+ res.end(renderRedirectPage(next.authorizeUrl));
119
+ return;
120
+ }
121
+ res.writeHead(200, { 'Content-Type': 'text/html' });
122
+ res.end(renderAuthPage('success', options.success.title, options.success.message));
123
+ // Let the response flush before tearing the server down.
124
+ setImmediate(() => {
125
+ closeServer();
126
+ resolve(tokens);
127
+ });
128
+ }
129
+ catch (error_) {
130
+ const failure = error_ instanceof Error ? error_ : new Error(String(error_));
131
+ if (failure instanceof TenantMismatchError) {
132
+ fail(res, 400, 'Tenant Switch Failed', failure);
133
+ }
134
+ else {
135
+ fail(res, 500, 'Authentication Failed', failure);
136
+ }
137
+ }
138
+ });
139
+ // Tracked so closeServer can destroy them.
140
+ server.on('connection', (socket) => {
141
+ connections.add(socket);
142
+ socket.on('close', () => connections.delete(socket));
143
+ });
144
+ server.on('error', (err) => {
145
+ closeServer();
146
+ reject(new Error(`Failed to start local server: ${err.message}`));
147
+ });
148
+ process.once('SIGINT', onSigint);
149
+ server.listen(CALLBACK_PORT, () => {
150
+ const first = prepared[0].authorizeUrl;
151
+ log('Opening browser for authentication...');
152
+ log(`If the browser doesn't open, visit: ${first}`);
153
+ openBrowser(first).catch((error) => {
154
+ // The URL was printed above, so a browser that fails to launch is not fatal.
155
+ log(`Could not open the browser automatically: ${error instanceof Error ? error.message : String(error)}`);
156
+ });
157
+ });
158
+ timeout.id = setTimeout(() => {
159
+ closeServer();
160
+ reject(new Error('Authentication timed out'));
161
+ }, FLOW_TIMEOUT_MS);
162
+ });
163
+ }
@@ -0,0 +1,5 @@
1
+ import { type Context } from '../config.js';
2
+ /** Guard for OAuth-only flows (browser login, token refresh, tenant switching); API keys are issued per tenant. */
3
+ export declare function requireOAuthContext(context: Context, action: string): void;
4
+ /** `client_id` is optional on Context (API-key contexts have none), so OAuth flows must assert it. */
5
+ export declare function requireClientId(context: Context): string;
@@ -0,0 +1,14 @@
1
+ import { getAuthType } from '../config.js';
2
+ /** Guard for OAuth-only flows (browser login, token refresh, tenant switching); API keys are issued per tenant. */
3
+ export function requireOAuthContext(context, action) {
4
+ if (getAuthType(context) === 'api_key') {
5
+ throw new Error(`${action} requires OAuth. Context "${context.name}" authenticates with an API key, which is already scoped to one tenant.`);
6
+ }
7
+ }
8
+ /** `client_id` is optional on Context (API-key contexts have none), so OAuth flows must assert it. */
9
+ export function requireClientId(context) {
10
+ if (!context.client_id) {
11
+ throw new Error(`Context "${context.name}" has no OAuth client ID. Recreate it with: aderis config add`);
12
+ }
13
+ return context.client_id;
14
+ }
@@ -0,0 +1,16 @@
1
+ import { AuthError } from './errors.js';
2
+ /**
3
+ * Upper bound for every auth-layer HTTP request (OIDC discovery, token
4
+ * endpoint); the browser-flow timer only starts after discovery.
5
+ */
6
+ export declare const HTTP_TIMEOUT_MS = 10000;
7
+ /** The one axios instance the auth layer uses, so the timeout applies everywhere. */
8
+ export declare const authHttp: import("axios").AxiosInstance;
9
+ /** HTTP status behind an error, whether axios raised it or an {@link AuthError} wrapped it. */
10
+ export declare function httpStatusOf(error: unknown): number | undefined;
11
+ /**
12
+ * Turns any failure of an auth-layer request into an {@link AuthError} naming
13
+ * `what` was attempted and what came back (status and OAuth error code, timeout
14
+ * or transport message), never the request itself. An {@link AuthError} passes through.
15
+ */
16
+ export declare function toAuthError(error: unknown, what: string): AuthError;
@@ -0,0 +1,49 @@
1
+ import axios from 'axios';
2
+ import { AuthError } from './errors.js';
3
+ /**
4
+ * Upper bound for every auth-layer HTTP request (OIDC discovery, token
5
+ * endpoint); the browser-flow timer only starts after discovery.
6
+ */
7
+ export const HTTP_TIMEOUT_MS = 10_000;
8
+ /** The one axios instance the auth layer uses, so the timeout applies everywhere. */
9
+ export const authHttp = axios.create({ timeout: HTTP_TIMEOUT_MS });
10
+ /** HTTP status behind an error, whether axios raised it or an {@link AuthError} wrapped it. */
11
+ export function httpStatusOf(error) {
12
+ if (error instanceof AuthError)
13
+ return error.status;
14
+ if (axios.isAxiosError(error))
15
+ return error.response?.status;
16
+ return undefined;
17
+ }
18
+ function isTimeout(error) {
19
+ return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT';
20
+ }
21
+ /** `error` / `error_description` from an OAuth error body (RFC 6749 §5.2), when the server sent them. */
22
+ function describeErrorBody(data) {
23
+ if (!data || typeof data !== 'object')
24
+ return '';
25
+ const { error, error_description: description } = data;
26
+ if (typeof error !== 'string' || !error)
27
+ return '';
28
+ return typeof description === 'string' && description ? `: ${error} (${description})` : `: ${error}`;
29
+ }
30
+ /**
31
+ * Turns any failure of an auth-layer request into an {@link AuthError} naming
32
+ * `what` was attempted and what came back (status and OAuth error code, timeout
33
+ * or transport message), never the request itself. An {@link AuthError} passes through.
34
+ */
35
+ export function toAuthError(error, what) {
36
+ if (error instanceof AuthError)
37
+ return error;
38
+ if (axios.isAxiosError(error)) {
39
+ if (error.response) {
40
+ const { status } = error.response;
41
+ return new AuthError(`${what} failed with HTTP ${status}${describeErrorBody(error.response.data)}`, { status });
42
+ }
43
+ if (isTimeout(error)) {
44
+ return new AuthError(`${what} timed out after ${error.config?.timeout ?? HTTP_TIMEOUT_MS} ms`);
45
+ }
46
+ return new AuthError(`${what} failed: ${error.message}`);
47
+ }
48
+ return new AuthError(`${what} failed: ${error instanceof Error ? error.message : String(error)}`);
49
+ }