@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.
- package/README.md +30 -8
- package/dist/commands/access-groups.d.ts +0 -1
- package/dist/commands/access-groups.js +54 -78
- package/dist/commands/account-transaction-allocations.d.ts +0 -1
- package/dist/commands/account-transaction-allocations.js +51 -75
- package/dist/commands/account-transactions.d.ts +0 -1
- package/dist/commands/account-transactions.js +80 -94
- package/dist/commands/accounts.d.ts +0 -1
- package/dist/commands/accounts.js +59 -83
- package/dist/commands/agent-platform.d.ts +0 -1
- package/dist/commands/agent-platform.js +58 -82
- package/dist/commands/agent-runs.d.ts +39 -0
- package/dist/commands/agent-runs.js +1155 -0
- package/dist/commands/analytics.d.ts +0 -1
- package/dist/commands/analytics.js +52 -76
- package/dist/commands/{text-templates.d.ts → api-keys.d.ts} +1 -2
- package/dist/commands/api-keys.js +1150 -0
- package/dist/commands/async-jobs.d.ts +39 -0
- package/dist/commands/async-jobs.js +1158 -0
- package/dist/commands/audit-log.d.ts +0 -1
- package/dist/commands/audit-log.js +51 -75
- package/dist/commands/auth/login.d.ts +9 -0
- package/dist/commands/auth/login.js +73 -20
- package/dist/commands/auth/logout.d.ts +4 -0
- package/dist/commands/auth/logout.js +4 -0
- package/dist/commands/auth/status.js +26 -17
- package/dist/commands/auth/tenant.js +3 -6
- package/dist/commands/auth.d.ts +0 -1
- package/dist/commands/auth.js +5 -34
- package/dist/commands/bank-accounts.d.ts +0 -1
- package/dist/commands/bank-accounts.js +68 -91
- package/dist/commands/bank-integrations.d.ts +1 -1
- package/dist/commands/bank-integrations.js +71 -92
- package/dist/commands/business-partners.d.ts +1 -1
- package/dist/commands/business-partners.js +84 -100
- package/dist/commands/chat.d.ts +0 -1
- package/dist/commands/chat.js +57 -80
- package/dist/commands/config/add.d.ts +3 -0
- package/dist/commands/config/add.js +34 -16
- package/dist/commands/config/current.js +3 -0
- package/dist/commands/config/list.js +3 -0
- package/dist/commands/credit-card-accounts.d.ts +1 -1
- package/dist/commands/credit-card-accounts.js +72 -90
- package/dist/commands/delivery-notes.d.ts +0 -1
- package/dist/commands/delivery-notes.js +83 -105
- package/dist/commands/dunning-configs.d.ts +0 -1
- package/dist/commands/dunning-configs.js +54 -78
- package/dist/commands/dunning-reminders.d.ts +0 -1
- package/dist/commands/dunning-reminders.js +57 -81
- package/dist/commands/dunning-runs.d.ts +0 -1
- package/dist/commands/dunning-runs.js +50 -74
- package/dist/commands/email-transactions.d.ts +0 -1
- package/dist/commands/email-transactions.js +52 -76
- package/dist/commands/employees.d.ts +0 -1
- package/dist/commands/employees.js +81 -105
- package/dist/commands/entity-configurations.d.ts +0 -1
- package/dist/commands/entity-configurations.js +52 -76
- package/dist/commands/event-processor-mappings.d.ts +0 -1
- package/dist/commands/event-processor-mappings.js +54 -78
- package/dist/commands/files.d.ts +0 -1
- package/dist/commands/files.js +61 -85
- package/dist/commands/health.d.ts +0 -1
- package/dist/commands/health.js +54 -78
- package/dist/commands/imports.d.ts +39 -0
- package/dist/commands/imports.js +1151 -0
- package/dist/commands/inbox.d.ts +0 -1
- package/dist/commands/inbox.js +57 -80
- package/dist/commands/integration-identities.d.ts +40 -0
- package/dist/commands/integration-identities.js +1151 -0
- package/dist/commands/integrations.d.ts +0 -1
- package/dist/commands/integrations.js +52 -76
- package/dist/commands/item-categories.d.ts +0 -1
- package/dist/commands/item-categories.js +54 -78
- package/dist/commands/items.d.ts +0 -1
- package/dist/commands/items.js +54 -78
- package/dist/commands/journal-lines.d.ts +0 -1
- package/dist/commands/journal-lines.js +50 -74
- package/dist/commands/journals.d.ts +1 -1
- package/dist/commands/journals.js +75 -96
- package/dist/commands/ledgers.d.ts +3 -2
- package/dist/commands/ledgers.js +100 -95
- package/dist/commands/my-account.d.ts +39 -0
- package/dist/commands/my-account.js +1160 -0
- package/dist/commands/office-locations.d.ts +0 -1
- package/dist/commands/office-locations.js +54 -78
- package/dist/commands/payment-methods.d.ts +0 -1
- package/dist/commands/payment-methods.js +59 -83
- package/dist/commands/payment-runs.d.ts +0 -1
- package/dist/commands/payment-runs.js +66 -90
- package/dist/commands/payment-terms.d.ts +0 -1
- package/dist/commands/payment-terms.js +55 -79
- package/dist/commands/payments.d.ts +0 -1
- package/dist/commands/payments.js +56 -80
- package/dist/commands/payroll-runs.d.ts +43 -0
- package/dist/commands/payroll-runs.js +1188 -0
- package/dist/commands/permissions.d.ts +39 -0
- package/dist/commands/permissions.js +1146 -0
- package/dist/commands/price-lists.d.ts +0 -1
- package/dist/commands/price-lists.js +59 -83
- package/dist/commands/processor-mappings.d.ts +0 -1
- package/dist/commands/processor-mappings.js +53 -77
- package/dist/commands/processors.d.ts +0 -1
- package/dist/commands/processors.js +60 -85
- package/dist/commands/purchase-order-types.d.ts +0 -1
- package/dist/commands/purchase-order-types.js +54 -78
- package/dist/commands/purchase-orders.d.ts +0 -1
- package/dist/commands/purchase-orders.js +74 -97
- package/dist/commands/reports.d.ts +0 -1
- package/dist/commands/reports.js +54 -78
- package/dist/commands/roles.d.ts +39 -0
- package/dist/commands/{text-templates.js → roles.js} +64 -90
- package/dist/commands/salary-types.d.ts +0 -1
- package/dist/commands/salary-types.js +54 -78
- package/dist/commands/sales-invoices.d.ts +0 -1
- package/dist/commands/sales-invoices.js +90 -110
- package/dist/commands/sales-orders.d.ts +0 -1
- package/dist/commands/sales-orders.js +83 -105
- package/dist/commands/saved-views.d.ts +0 -1
- package/dist/commands/saved-views.js +52 -76
- package/dist/commands/scheduled-processor-mappings.d.ts +0 -1
- package/dist/commands/scheduled-processor-mappings.js +54 -78
- package/dist/commands/schedules.d.ts +0 -1
- package/dist/commands/schedules.js +58 -82
- package/dist/commands/switch.js +4 -4
- package/dist/commands/tag-configurations.d.ts +0 -1
- package/dist/commands/tag-configurations.js +56 -79
- package/dist/commands/tags.d.ts +0 -1
- package/dist/commands/tags.js +54 -78
- package/dist/commands/tax-codes.d.ts +0 -1
- package/dist/commands/tax-codes.js +57 -81
- package/dist/commands/tax-matrix.d.ts +0 -1
- package/dist/commands/tax-matrix.js +54 -78
- package/dist/commands/templates.d.ts +39 -0
- package/dist/commands/templates.js +1157 -0
- package/dist/commands/tenants.d.ts +0 -1
- package/dist/commands/tenants.js +58 -81
- package/dist/commands/tools.d.ts +0 -1
- package/dist/commands/tools.js +51 -75
- package/dist/commands/users.d.ts +42 -0
- package/dist/commands/users.js +1174 -0
- package/dist/commands/vat-declarations.d.ts +3 -1
- package/dist/commands/vat-declarations.js +78 -93
- package/dist/commands/vendor-invoices.d.ts +0 -1
- package/dist/commands/vendor-invoices.js +87 -110
- package/dist/commands/webhooks.d.ts +0 -1
- package/dist/commands/webhooks.js +56 -80
- package/dist/commands/workflows.d.ts +0 -1
- package/dist/commands/workflows.js +52 -76
- package/dist/lib/api-client.d.ts +6 -0
- package/dist/lib/api-client.js +16 -6
- package/dist/lib/auth/constants.d.ts +11 -0
- package/dist/lib/auth/constants.js +11 -0
- package/dist/lib/auth/errors.d.ts +17 -0
- package/dist/lib/auth/errors.js +22 -0
- package/dist/lib/auth/flow.d.ts +27 -0
- package/dist/lib/auth/flow.js +163 -0
- package/dist/lib/auth/guards.d.ts +5 -0
- package/dist/lib/auth/guards.js +14 -0
- package/dist/lib/auth/http.d.ts +16 -0
- package/dist/lib/auth/http.js +49 -0
- package/dist/lib/auth/index.d.ts +49 -0
- package/dist/lib/auth/index.js +228 -0
- package/dist/lib/auth/jwks.d.ts +19 -0
- package/dist/lib/auth/jwks.js +112 -0
- package/dist/lib/auth/keycloak-provider.d.ts +91 -0
- package/dist/lib/auth/keycloak-provider.js +244 -0
- package/dist/lib/auth/pages.d.ts +25 -0
- package/dist/lib/auth/pages.js +123 -0
- package/dist/lib/auth/pkce.d.ts +11 -0
- package/dist/lib/auth/pkce.js +17 -0
- package/dist/lib/auth/tokens.d.ts +59 -0
- package/dist/lib/auth/tokens.js +105 -0
- package/dist/lib/auth/types.d.ts +61 -0
- package/dist/lib/auth/types.js +1 -0
- package/dist/lib/auth/validated-url.d.ts +6 -0
- package/dist/lib/auth/validated-url.js +53 -0
- package/dist/lib/config.d.ts +13 -3
- package/dist/lib/config.js +24 -19
- package/oclif.manifest.json +2447 -641
- package/package.json +5 -4
- package/dist/lib/auth.d.ts +0 -43
- 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
|
|
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:
|
|
261
|
+
// Binary response: to -f / --file, or to stdout when it is redirected.
|
|
260
262
|
if (endpoint.isBinaryResponse) {
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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;
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
-
import { getAuthHeaders } from './auth.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|