iit-mcp-autotask 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/main.js +7 -0
- package/dist/moves.d.ts +5 -0
- package/dist/moves.js +84 -0
- package/dist/tools/context/client.d.ts +23 -0
- package/dist/tools/context/client.js +96 -0
- package/dist/tools/context/derive.d.ts +9 -0
- package/dist/tools/context/derive.js +36 -0
- package/dist/tools/context/ops.d.ts +29 -0
- package/dist/tools/context/ops.js +70 -0
- package/dist/tools/context/pool.d.ts +5 -0
- package/dist/tools/context/pool.js +9 -0
- package/dist/tools/context/scope.d.ts +24 -0
- package/dist/tools/context/scope.js +165 -0
- package/dist/tools/context/types.d.ts +150 -0
- package/dist/tools/context/types.js +2 -0
- package/dist/tools/context.d.ts +6 -199
- package/dist/tools/context.js +5 -393
- package/dist/tools/results/envelope.d.ts +54 -0
- package/dist/tools/results/envelope.js +44 -0
- package/dist/tools/results/errors.d.ts +165 -0
- package/dist/tools/results/errors.js +402 -0
- package/dist/tools/results/untrusted.d.ts +124 -0
- package/dist/tools/results/untrusted.js +238 -0
- package/dist/tools/results.d.ts +19 -185
- package/dist/tools/results.js +3 -675
- package/dist/tools/schemas.d.ts +1 -1
- package/dist/tools/server.d.ts +2 -2
- package/dist/tools/server.js +1 -1
- package/dist/tools/write/apply-phases.d.ts +95 -0
- package/dist/tools/write/apply-phases.js +431 -0
- package/dist/tools/write/apply.d.ts +4 -0
- package/dist/tools/write/apply.js +41 -0
- package/dist/tools/write/common.d.ts +23 -0
- package/dist/tools/write/common.js +33 -0
- package/dist/tools/write/facts.d.ts +56 -0
- package/dist/tools/write/facts.js +98 -0
- package/dist/tools/write/outcome.d.ts +27 -0
- package/dist/tools/write/outcome.js +87 -0
- package/dist/tools/write/parse.d.ts +14 -0
- package/dist/tools/write/parse.js +69 -0
- package/dist/tools/write/payload.d.ts +44 -0
- package/dist/tools/write/payload.js +118 -0
- package/dist/tools/write/preview.d.ts +3 -0
- package/dist/tools/write/preview.js +175 -0
- package/dist/tools/write/snapshot.d.ts +55 -0
- package/dist/tools/write/snapshot.js +237 -0
- package/dist/tools/write.d.ts +28 -33
- package/dist/tools/write.js +5 -1154
- package/dist/write/store/audit-chain.d.ts +5 -0
- package/dist/write/store/audit-chain.js +23 -0
- package/dist/write/store/audit-log.d.ts +13 -0
- package/dist/write/store/audit-log.js +55 -0
- package/dist/write/store/constants.d.ts +34 -0
- package/dist/write/store/constants.js +35 -0
- package/dist/write/store/files.d.ts +44 -0
- package/dist/write/store/files.js +157 -0
- package/dist/write/store/ledger.d.ts +18 -0
- package/dist/write/store/ledger.js +153 -0
- package/dist/write/store/lock.d.ts +97 -0
- package/dist/write/store/lock.js +446 -0
- package/dist/write/store/op-lock.d.ts +13 -0
- package/dist/write/store/op-lock.js +175 -0
- package/dist/write/store/plan-store.d.ts +10 -0
- package/dist/write/store/plan-store.js +102 -0
- package/dist/write/store/plans.d.ts +5 -0
- package/dist/write/store/plans.js +19 -0
- package/dist/write/store/types.d.ts +273 -0
- package/dist/write/store/types.js +2 -0
- package/dist/write/store/unresolved.d.ts +9 -0
- package/dist/write/store/unresolved.js +45 -0
- package/dist/write/store.d.ts +6 -267
- package/dist/write/store.js +34 -1008
- package/package.json +1 -1
- package/server.json +1 -1
package/README.md
CHANGED
|
@@ -155,7 +155,13 @@ What the policy can and cannot do:
|
|
|
155
155
|
original (the vendor ignores an update of `ticketID`): the new record gets a new id, create date and
|
|
156
156
|
creator, and notifications may fire. A move into another company or contract always needs a signed
|
|
157
157
|
approval grant; an unfinished earlier move of the same source blocks another until its ledger entry ages
|
|
158
|
-
out (14 days)
|
|
158
|
+
out (14 days) or a person clears it: in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set,
|
|
159
|
+
`autotask-mcp moves list` shows the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you
|
|
160
|
+
checked both tickets in Autotask; you type the source id, and the copy's id when the ledger names one) closes the
|
|
161
|
+
ledger lines as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
|
|
162
|
+
(its engine may still be running) and, while the engine may still run, when the sending server is still registered or alive. A plan previewed
|
|
163
|
+
before the resolve cannot apply after it. It is not an
|
|
164
|
+
MCP tool; like the approvals CLI it is a guardrail, not a boundary against an agent with a same-user shell. Billed, attached, approved, closed-target and older-than-14-days sources are refused.
|
|
159
165
|
- Hard rules no policy overrides: `authenticate.*` is never served; operations the SDK flags
|
|
160
166
|
`requiresApproval`, and non-read sensitive ones (webhooks, `resources.transferOwnership`), always
|
|
161
167
|
need approval; destructive and account-wide operations need it, and `allowUnattended` cannot waive
|
package/dist/main.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { createMcpHandler } from '@modelcontextprotocol/server';
|
|
13
13
|
import { ConfigError, loadConfig, serverCredentialDefaults } from './config.js';
|
|
14
14
|
import { runApprovals } from './approvals.js';
|
|
15
|
+
import { runMoves } from './moves.js';
|
|
15
16
|
import { runDoctor } from './doctor.js';
|
|
16
17
|
import { createLogger } from './logger.js';
|
|
17
18
|
import { assertPlanHash } from './tools/catalog-data.js';
|
|
@@ -66,6 +67,8 @@ Usage: autotask-mcp [flags]
|
|
|
66
67
|
approvals list|show|approve|reject <handle>
|
|
67
68
|
approvalMode external: a human decides a pending write (run it
|
|
68
69
|
yourself in a terminal; see the README); keygen <id> --ops <op,..|*>
|
|
70
|
+
moves list | resolve <resource>:<id>
|
|
71
|
+
a human clears an unfinished move's MOVE_UNRESOLVED block (terminal only)
|
|
69
72
|
--transport <stdio|http> the transport (default: $MCP_TRANSPORT, else http)
|
|
70
73
|
--host <address> the http bind address (default: $MCP_HOST, else 127.0.0.1;
|
|
71
74
|
off loopback, AUTOTASK_HTTP_ALLOWED_HOSTS and AUTOTASK_HTTP_AUTH
|
|
@@ -125,6 +128,10 @@ function main() {
|
|
|
125
128
|
process.exitCode = runApprovals(process.argv.slice(2), process.env, (t) => process.stdout.write(`${t}\n`));
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
131
|
+
if (process.argv[2] === 'moves') {
|
|
132
|
+
process.exitCode = runMoves(process.argv.slice(2), process.env, (t) => process.stdout.write(`${t}\n`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
128
135
|
const args = parseCliArgs(process.argv.slice(2));
|
|
129
136
|
// --version / --help predates the logger and the config: they print to stdout and exit 0.
|
|
130
137
|
// (Safe on stdout: no stdio MCP session starts before this point.)
|
package/dist/moves.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Prompter } from './approvals.js';
|
|
2
|
+
/** Run a `moves` subcommand; returns the process exit code. `argv` is everything after `autotask-mcp`. */
|
|
3
|
+
export declare function runMoves(argv: string[], env: Record<string, string | undefined>, out: (t: string) => void, prompter?: () => Prompter & {
|
|
4
|
+
close(): void;
|
|
5
|
+
}, now?: () => number): number;
|
package/dist/moves.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* moves.ts — the `autotask-mcp moves` subcommands (issue #131): a person clears the MOVE_UNRESOLVED block on ONE move
|
|
3
|
+
* source after checking both tickets in Autotask. Run in a terminal as the server's OS user with AUTOTASK_STATE_DIR set.
|
|
4
|
+
*
|
|
5
|
+
* `list` only scans `ledger.jsonl` (it never opens the store: that registers a holder, rewrites the ledger and prunes).
|
|
6
|
+
* `resolve` shows the retained lines, takes the typed confirmation on /dev/tty, and ONLY THEN opens the store, calls
|
|
7
|
+
* `resolveMove` (ledger lines and the audit line under the op lock) and closes it: the blocking /dev/tty read must never
|
|
8
|
+
* sit between a lock and its heartbeat. It prints ids and codes only, never vendor text.
|
|
9
|
+
*
|
|
10
|
+
* Guardrail, not a boundary: an agent with a same-user shell can drive a pty or edit `ledger.jsonl` itself. Nothing on the
|
|
11
|
+
* MCP surface reaches this.
|
|
12
|
+
*/
|
|
13
|
+
import { userInfo } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { ApprovalsError, ttyPrompter } from './approvals.js';
|
|
16
|
+
import { createLogger } from './logger.js';
|
|
17
|
+
import { openStateStore, unresolvedMoves } from './write/store.js';
|
|
18
|
+
const MOVES_USAGE = `usage: autotask-mcp moves list | moves resolve <time_entries|ticket_charges>:<id>
|
|
19
|
+
Run it yourself, in a terminal, as the server's OS user, with AUTOTASK_STATE_DIR set to the value the server runs with.`;
|
|
20
|
+
const SOURCE = /^(time_entries|ticket_charges):([1-9]\d{0,14})$/;
|
|
21
|
+
const fail = (message) => {
|
|
22
|
+
throw new ApprovalsError(message);
|
|
23
|
+
};
|
|
24
|
+
const describe = (m, now) => `${m.targetKey} ${m.state}${m.code !== undefined ? ` ${m.code}` : ''} source ${m.sourceId ?? m.targetKey.split(':')[1]}${m.newId !== undefined ? ` new ${m.newId}` : ''} sent ${Math.round((now - m.sentAt) / 60_000)} min ago`;
|
|
25
|
+
/** Run a `moves` subcommand; returns the process exit code. `argv` is everything after `autotask-mcp`. */
|
|
26
|
+
export function runMoves(argv, env, out, prompter = ttyPrompter, now = Date.now) {
|
|
27
|
+
try {
|
|
28
|
+
const [cmd, sub, source, ...extra] = argv;
|
|
29
|
+
if (cmd !== 'moves' || sub === undefined || extra.length > 0)
|
|
30
|
+
fail(MOVES_USAGE);
|
|
31
|
+
const dir = env.AUTOTASK_STATE_DIR;
|
|
32
|
+
if (dir === undefined)
|
|
33
|
+
fail('AUTOTASK_STATE_DIR must be set to the value the server runs with');
|
|
34
|
+
const ledgerPath = join(dir, 'ledger.jsonl');
|
|
35
|
+
if (sub === 'list') {
|
|
36
|
+
const rows = unresolvedMoves(ledgerPath);
|
|
37
|
+
out(rows.length === 0 ? 'No unfinished moves are retained.' : rows.map((m) => describe(m, now())).join('\n'));
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const m = sub === 'resolve' && source !== undefined ? SOURCE.exec(source) : null;
|
|
41
|
+
if (m === null)
|
|
42
|
+
fail(MOVES_USAGE);
|
|
43
|
+
const [resource, id] = [m[1], Number(m[2])];
|
|
44
|
+
const rows = unresolvedMoves(ledgerPath, `${resource}:${id}`);
|
|
45
|
+
if (rows.length === 0) {
|
|
46
|
+
out(`Nothing to resolve: no unfinished move of ${resource}:${id} is retained.`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const p = prompter();
|
|
50
|
+
try {
|
|
51
|
+
p.say(`Unfinished move(s) of ${resource}:${id}:\n${rows.map((r) => ` ${describe(r, now())}`).join('\n')}`);
|
|
52
|
+
p.say('Before resolving, check BOTH tickets in Autotask: the source may still exist, and a copy may exist on the target (a second move would make another). Resolving lets the agent move this source again.');
|
|
53
|
+
if (p.ask(`Type ${resource}:${id} to resolve it: `) !== `${resource}:${id}`)
|
|
54
|
+
fail('not resolved: the typed text did not match');
|
|
55
|
+
for (const newId of new Set(rows.flatMap((r) => (r.newId === undefined ? [] : [r.newId])))) {
|
|
56
|
+
if (p.ask(`The ledger names a copy, new id ${newId}. Look at it in Autotask, then type its id: `) !== String(newId))
|
|
57
|
+
fail('not resolved: the typed copy id did not match');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
p.close();
|
|
62
|
+
}
|
|
63
|
+
const store = openStateStore(dir, { log: createLogger('error'), now });
|
|
64
|
+
try {
|
|
65
|
+
const r = store.resolveMove(resource, id, userInfo().username, rows.map((x) => ({ idemKey: x.idemKey, state: x.state, ...(x.newId !== undefined ? { newId: x.newId } : {}) })), now());
|
|
66
|
+
if (!r.ok)
|
|
67
|
+
fail(`not resolved (${r.reason}): ${r.detail}`);
|
|
68
|
+
out(`Resolved ${resource}:${id} (${r.ok ? r.resolved : 0} ledger line(s) closed as MOVE_RESOLVED, audited). The agent may now move it again.`);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
store.close();
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err instanceof ApprovalsError) {
|
|
77
|
+
out(err.message);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
out(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=moves.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AutotaskConfig } from 'node-autotask';
|
|
2
|
+
import type { Config } from '../../config.js';
|
|
3
|
+
import type { Logger } from '../../logger.js';
|
|
4
|
+
import type { ToolClient } from './types.js';
|
|
5
|
+
/** The reserved placeholder origin (RFC 6761: nothing in the real world resolves it). */
|
|
6
|
+
export declare const PLACEHOLDER_ORIGIN = "https://no-autotask-origin.invalid";
|
|
7
|
+
export declare const PLACEHOLDER_BASE_URL = "https://no-autotask-origin.invalid/atservicesrest";
|
|
8
|
+
/** Sentinel credential values a placeholder client is constructed with (never real, never dialled). */
|
|
9
|
+
export declare const SENTINEL_CREDENTIAL = "unresolved-request-scope";
|
|
10
|
+
/**
|
|
11
|
+
* The deployment-wide SDK options every client this factory builds gets (the agent profile +
|
|
12
|
+
* the env overrides + the cache preset + the literal-IP host floor + the audit sink).
|
|
13
|
+
* Precedence: explicit env > `agent` profile > SDK default (the profile fills only what is
|
|
14
|
+
* left unset).
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildSdkBaseOptions(config: Config, log: Logger): Omit<AutotaskConfig, 'apiIntegrationCode' | 'userName' | 'secret'>;
|
|
17
|
+
/** A fail-closed placeholder client: constructed but never dialled (context refuses pre-request). */
|
|
18
|
+
export declare function buildPlaceholderClient(baseOptions: ReturnType<typeof buildSdkBaseOptions>): ToolClient;
|
|
19
|
+
/** The one process-lifetime client of the stdio transport (the environment IS the identity). */
|
|
20
|
+
export declare function buildStdioClient(config: Config, log: Logger): {
|
|
21
|
+
client: ToolClient;
|
|
22
|
+
origin: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** SDK client construction: the base options, the placeholder client and the stdio client (issue #91). */
|
|
2
|
+
import { AutotaskClient } from 'node-autotask';
|
|
3
|
+
import { serverCredentialDefaults } from '../../config.js';
|
|
4
|
+
import { normalizeBaseUrl } from '../../base-hosts.js';
|
|
5
|
+
import { cacheOptionsForPreset } from '../catalog-data.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// SDK client construction
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** The reserved placeholder origin (RFC 6761: nothing in the real world resolves it). */
|
|
10
|
+
export const PLACEHOLDER_ORIGIN = 'https://no-autotask-origin.invalid';
|
|
11
|
+
export const PLACEHOLDER_BASE_URL = `${PLACEHOLDER_ORIGIN}/atservicesrest`;
|
|
12
|
+
/** Sentinel credential values a placeholder client is constructed with (never real, never dialled). */
|
|
13
|
+
export const SENTINEL_CREDENTIAL = 'unresolved-request-scope';
|
|
14
|
+
/**
|
|
15
|
+
* The deployment-wide SDK options every client this factory builds gets (the agent profile +
|
|
16
|
+
* the env overrides + the cache preset + the literal-IP host floor + the audit sink).
|
|
17
|
+
* Precedence: explicit env > `agent` profile > SDK default (the profile fills only what is
|
|
18
|
+
* left unset).
|
|
19
|
+
*/
|
|
20
|
+
export function buildSdkBaseOptions(config, log) {
|
|
21
|
+
const defaultBase = config.AUTOTASK_BASE_URL;
|
|
22
|
+
const defaultBaseUrl = defaultBase === undefined ? undefined : normalizeBaseUrl(defaultBase)?.baseUrl;
|
|
23
|
+
return {
|
|
24
|
+
// The vendor budget guard: never the `library` defaults, never an operator-tuned value
|
|
25
|
+
// above the vendor cap (config validation enforces the cap).
|
|
26
|
+
profile: 'agent',
|
|
27
|
+
...(config.AUTOTASK_RATE_LIMIT_PER_HOUR !== undefined ? { rateLimit: { perHour: config.AUTOTASK_RATE_LIMIT_PER_HOUR } } : {}),
|
|
28
|
+
...(config.AUTOTASK_MAX_CONCURRENT !== undefined ? { maxConcurrent: config.AUTOTASK_MAX_CONCURRENT } : {}),
|
|
29
|
+
...(config.AUTOTASK_TIMEOUT_MS !== undefined ? { timeoutMs: config.AUTOTASK_TIMEOUT_MS } : {}),
|
|
30
|
+
...(config.AUTOTASK_MAX_RETRIES !== undefined ? { maxRetries: config.AUTOTASK_MAX_RETRIES } : {}),
|
|
31
|
+
// The literal-IP SSRF floor: a LITERAL private/loopback origin is refused at construction
|
|
32
|
+
// even if the deployment allow-list named it (the private bound wins — security S2/C2.4).
|
|
33
|
+
hostPolicy: {
|
|
34
|
+
blockPrivateHosts: true,
|
|
35
|
+
...(defaultBaseUrl !== undefined ? { allowedOrigins: [defaultBaseUrl] } : {}),
|
|
36
|
+
},
|
|
37
|
+
// The SCOPING Stage-4 cache deviation (user-approved): `on` sends no cache blocks (the SDK
|
|
38
|
+
// default-on caches) UNLESS the AUTOTASK_FIELD_CACHE_TTL_MS override is set — then the
|
|
39
|
+
// scalar fieldCache block rides (the TTL pass-through); `off` opts every channel out
|
|
40
|
+
// explicitly (every read re-dials the vendor) and is never touched by the override.
|
|
41
|
+
...cacheOptionsForPreset(config.AUTOTASK_CACHE_PRESET, config.AUTOTASK_FIELD_CACHE_TTL_MS),
|
|
42
|
+
// One bounded line per settled request (the SDK stamps the correlation id and redacts the
|
|
43
|
+
// body at the wire; the `path` carries no query values by construction). Only the event's
|
|
44
|
+
// own shape metadata is forwarded — never bodies or payload values (security S9).
|
|
45
|
+
onAudit: (event) => {
|
|
46
|
+
const e = event;
|
|
47
|
+
log.info('autotask request', {
|
|
48
|
+
...(typeof e.correlationId === 'string' ? { correlationId: e.correlationId } : {}),
|
|
49
|
+
...(typeof e.operation === 'string' ? { operation: e.operation } : {}),
|
|
50
|
+
...(typeof e.method === 'string' ? { method: e.method } : {}),
|
|
51
|
+
...(typeof e.path === 'string' ? { path: e.path } : {}),
|
|
52
|
+
...(typeof e.status === 'number' ? { status: e.status } : {}),
|
|
53
|
+
...(typeof e.durationMs === 'number' ? { durationMs: e.durationMs } : {}),
|
|
54
|
+
...(typeof e.errorCode === 'string' ? { errorCode: e.errorCode } : {}),
|
|
55
|
+
...(e.notSent === true ? { notSent: true } : {}),
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** A fail-closed placeholder client: constructed but never dialled (context refuses pre-request). */
|
|
61
|
+
export function buildPlaceholderClient(baseOptions) {
|
|
62
|
+
// The placeholders keep the literal-IP floor only: their reserved origin must survive
|
|
63
|
+
// construction so the sentinel is what a dialled placeholder would present, unmistakably.
|
|
64
|
+
const client = new AutotaskClient({
|
|
65
|
+
...baseOptions,
|
|
66
|
+
apiIntegrationCode: SENTINEL_CREDENTIAL,
|
|
67
|
+
userName: SENTINEL_CREDENTIAL,
|
|
68
|
+
secret: SENTINEL_CREDENTIAL,
|
|
69
|
+
baseUrl: PLACEHOLDER_BASE_URL,
|
|
70
|
+
hostPolicy: { blockPrivateHosts: true },
|
|
71
|
+
});
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
/** The one process-lifetime client of the stdio transport (the environment IS the identity). */
|
|
75
|
+
export function buildStdioClient(config, log) {
|
|
76
|
+
const baseOptions = buildSdkBaseOptions(config, log);
|
|
77
|
+
const defaults = serverCredentialDefaults(config);
|
|
78
|
+
if (defaults === undefined || config.AUTOTASK_BASE_URL === undefined) {
|
|
79
|
+
// Unreachable: loadConfig requires all four under stdio. The line after it is unreachable;
|
|
80
|
+
// it exists so the narrowing is a type fact rather than a comment.
|
|
81
|
+
throw new Error('stdio client built without a complete credential tuple — loadConfig guarantees this cannot happen');
|
|
82
|
+
}
|
|
83
|
+
const normalized = normalizeBaseUrl(config.AUTOTASK_BASE_URL);
|
|
84
|
+
if (normalized === undefined)
|
|
85
|
+
throw new Error('AUTOTASK_BASE_URL failed normalization after boot validation — this cannot happen');
|
|
86
|
+
const client = new AutotaskClient({
|
|
87
|
+
...baseOptions,
|
|
88
|
+
apiIntegrationCode: defaults.apiIntegrationCode,
|
|
89
|
+
userName: defaults.userName,
|
|
90
|
+
secret: defaults.secret,
|
|
91
|
+
...(defaults.impersonationResourceId !== undefined ? { impersonationResourceId: defaults.impersonationResourceId } : {}),
|
|
92
|
+
baseUrl: normalized.baseUrl,
|
|
93
|
+
});
|
|
94
|
+
return { client, origin: normalized.origin };
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolDeps, ToolContext } from './types.js';
|
|
2
|
+
/** Derive the context the registrations share. */
|
|
3
|
+
export declare function deriveToolContext(deps: ToolDeps, served?: ReadonlySet<string>): ToolContext;
|
|
4
|
+
/**
|
|
5
|
+
* A vendor 401 (or impersonation-scope 401/403) deletes the pooled entry so a stale identity is
|
|
6
|
+
* not negative-cached and replayed (security S1/C1.8, S3/C3.5): the next request with the same
|
|
7
|
+
* tuple recreates the client and starts fresh. Safe to call with a non-pooled client (no-op).
|
|
8
|
+
*/
|
|
9
|
+
export declare function releaseClientOnError(deps: ToolDeps, err: unknown): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Deriving a tool context from the deps, and releasing a client on error (issue #91). */
|
|
2
|
+
import { isAutotaskError } from 'node-autotask';
|
|
3
|
+
import { recordById, toolDescription, untrustedFieldsOf } from '../catalog-data.js';
|
|
4
|
+
/** Derive the context the registrations share. */
|
|
5
|
+
export function deriveToolContext(deps, served = new Set()) {
|
|
6
|
+
return {
|
|
7
|
+
...deps,
|
|
8
|
+
served,
|
|
9
|
+
writable: new Set(deps.write === undefined ? [] : Object.keys(deps.write.runtime.loaded.policy.ops)),
|
|
10
|
+
record: recordById,
|
|
11
|
+
untrustedFields: (operation) => {
|
|
12
|
+
const rec = recordById(operation);
|
|
13
|
+
return rec === undefined ? [] : untrustedFieldsOf(rec);
|
|
14
|
+
},
|
|
15
|
+
description: toolDescription,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A vendor 401 (or impersonation-scope 401/403) deletes the pooled entry so a stale identity is
|
|
20
|
+
* not negative-cached and replayed (security S1/C1.8, S3/C3.5): the next request with the same
|
|
21
|
+
* tuple recreates the client and starts fresh. Safe to call with a non-pooled client (no-op).
|
|
22
|
+
*/
|
|
23
|
+
export function releaseClientOnError(deps, err) {
|
|
24
|
+
if (deps.pool === undefined || deps.poolConfig === undefined)
|
|
25
|
+
return;
|
|
26
|
+
const code = err?.code;
|
|
27
|
+
const name = err?.name;
|
|
28
|
+
if (isAutotaskError(err) &&
|
|
29
|
+
((code === 'UNAUTHORIZED' || code === 'FORBIDDEN') ||
|
|
30
|
+
name === 'UnauthorizedError' ||
|
|
31
|
+
name === 'ForbiddenError')) {
|
|
32
|
+
const removed = deps.pool.delete(deps.poolConfig);
|
|
33
|
+
deps.log.info('deleted pooled client after credential refusal', { removed, poolSize: deps.pool.size });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=derive.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ToolClient, QueryWalkParams, QueryWalkResult, ToolOps } from './types.js';
|
|
2
|
+
/** The real SDK bindings (the production default; tests substitute a fixture-backed bundle). */
|
|
3
|
+
/**
|
|
4
|
+
* The SDK operations bundle (the production default; tests substitute a fixture-backed bundle).
|
|
5
|
+
*
|
|
6
|
+
* (Historical, FT-4: before node-autotask 0.9.0 the invoke router resolved `client['authenticate']` to the
|
|
7
|
+
* convenience METHOD, so `authenticate.get` answered CONFIG_ERROR and this bundle special-cased it to
|
|
8
|
+
* `authenticateResource.get()`. 0.9.0 routes it to the resource holder (#88), so every read now goes through
|
|
9
|
+
* `invokeRead` and its governor; `authenticate.*` is refused by the deployment policy before it.)
|
|
10
|
+
*/
|
|
11
|
+
export declare const defaultToolOps: ToolOps;
|
|
12
|
+
/**
|
|
13
|
+
* Walk one `*.query` operation with the resource's OWN iterator (`client[<resource>].query(...)` —
|
|
14
|
+
* the public AsyncIterable over the SDK's internal page fetcher; see {@link ToolOps.queryWalk}).
|
|
15
|
+
* The page size is the vendor maximum (500) and `maxRows` caps the TOTAL rows.
|
|
16
|
+
*
|
|
17
|
+
* Truncation honesty: the iterator is advanced ONE record past the cap purely to learn whether more
|
|
18
|
+
* records exist; that record is DISCARDED, so a data set of exactly `maxRows` rows reports
|
|
19
|
+
* `truncated: false` and the page count is untouched.
|
|
20
|
+
*/
|
|
21
|
+
export declare function walkQuery(client: ToolClient, operation: string, params: QueryWalkParams, maxRows: number): Promise<QueryWalkResult>;
|
|
22
|
+
/**
|
|
23
|
+
* Propagate the MCP request's cancellation into the SDK's in-flight request (architecture 7.6:
|
|
24
|
+
* an abandoned request stops consuming vendor budget). The SDK's `withSignal` returns
|
|
25
|
+
* `undefined` when the signal has already aborted or no transport is attached — the SDK's own
|
|
26
|
+
* handler then answers the typed cancellation; the handler's generic catch would otherwise
|
|
27
|
+
* re-report it as a tool error, so this wrapper throws the SDK's typed `AbortError` instead.
|
|
28
|
+
*/
|
|
29
|
+
export declare function withSignal(client: ToolClient, signal: AbortSignal | undefined, fn: () => Promise<unknown>): Promise<unknown>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The default tool ops: the bounded query walk and the abort-signal wrapper (issue #91). */
|
|
2
|
+
import { AutotaskClient, camelKey } from 'node-autotask';
|
|
3
|
+
import { AutotaskConfigError } from 'node-autotask';
|
|
4
|
+
import { fetchByIds, fetchMany, invokeDelete, invokeRead, invokeWrite, relationshipMap, resolveAny, searchAcrossResources } from 'node-autotask/operations';
|
|
5
|
+
/** The real SDK bindings (the production default; tests substitute a fixture-backed bundle). */
|
|
6
|
+
/**
|
|
7
|
+
* The SDK operations bundle (the production default; tests substitute a fixture-backed bundle).
|
|
8
|
+
*
|
|
9
|
+
* (Historical, FT-4: before node-autotask 0.9.0 the invoke router resolved `client['authenticate']` to the
|
|
10
|
+
* convenience METHOD, so `authenticate.get` answered CONFIG_ERROR and this bundle special-cased it to
|
|
11
|
+
* `authenticateResource.get()`. 0.9.0 routes it to the resource holder (#88), so every read now goes through
|
|
12
|
+
* `invokeRead` and its governor; `authenticate.*` is refused by the deployment policy before it.)
|
|
13
|
+
*/
|
|
14
|
+
export const defaultToolOps = {
|
|
15
|
+
invokeRead: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeRead(client, operation, args, opts ?? {})),
|
|
16
|
+
invokeWrite: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeWrite(client, operation, args, opts ?? {})),
|
|
17
|
+
invokeDelete: (client, operation, args, opts, signal) => withSignal(client, signal, () => invokeDelete(client, operation, args, opts ?? {})),
|
|
18
|
+
resolveAny: (client, identifier, opts, signal) => withSignal(client, signal, () => resolveAny(client, identifier, opts ?? {})),
|
|
19
|
+
searchAcrossResources: (client, query, opts, signal) => withSignal(client, signal, () => searchAcrossResources(client, query, opts ?? {})),
|
|
20
|
+
fetchMany: (client, items, opts, signal) => withSignal(client, signal, () => fetchMany(client, items, opts ?? {})),
|
|
21
|
+
fetchByIds: (client, items, opts, signal) => withSignal(client, signal, () => fetchByIds(client, items, opts ?? {})),
|
|
22
|
+
relationshipMap: (client, resourceKey) => relationshipMap(client, resourceKey),
|
|
23
|
+
hourlyBudget: (client) => Promise.resolve(client.hourlyBudget()),
|
|
24
|
+
queryWalk: (client, operation, params, maxRows, signal) => withSignal(client, signal, () => walkQuery(client, operation, params, maxRows)),
|
|
25
|
+
freshen: (client) => client.http.readCache?.clear(),
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Walk one `*.query` operation with the resource's OWN iterator (`client[<resource>].query(...)` —
|
|
29
|
+
* the public AsyncIterable over the SDK's internal page fetcher; see {@link ToolOps.queryWalk}).
|
|
30
|
+
* The page size is the vendor maximum (500) and `maxRows` caps the TOTAL rows.
|
|
31
|
+
*
|
|
32
|
+
* Truncation honesty: the iterator is advanced ONE record past the cap purely to learn whether more
|
|
33
|
+
* records exist; that record is DISCARDED, so a data set of exactly `maxRows` rows reports
|
|
34
|
+
* `truncated: false` and the page count is untouched.
|
|
35
|
+
*/
|
|
36
|
+
export async function walkQuery(client, operation, params, maxRows) {
|
|
37
|
+
const resourceKey = operation.slice(0, operation.indexOf('.'));
|
|
38
|
+
const holder = client[camelKey(resourceKey)];
|
|
39
|
+
if (holder === undefined || typeof holder.query !== 'function') {
|
|
40
|
+
throw new AutotaskConfigError(`output_path: the client exposes no query iterator for "${resourceKey}" (operation ${operation}) — nothing was written`, { operation });
|
|
41
|
+
}
|
|
42
|
+
const bag = { maxRecords: 500 };
|
|
43
|
+
if (params.filter !== undefined)
|
|
44
|
+
bag.filter = params.filter;
|
|
45
|
+
if (params.includeFields !== undefined)
|
|
46
|
+
bag.includeFields = params.includeFields;
|
|
47
|
+
if (params.via !== undefined)
|
|
48
|
+
bag.via = params.via;
|
|
49
|
+
const items = [];
|
|
50
|
+
for await (const item of holder.query(bag)) {
|
|
51
|
+
if (items.length >= maxRows)
|
|
52
|
+
return { items, truncated: true };
|
|
53
|
+
items.push(item);
|
|
54
|
+
}
|
|
55
|
+
return { items, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Propagate the MCP request's cancellation into the SDK's in-flight request (architecture 7.6:
|
|
59
|
+
* an abandoned request stops consuming vendor budget). The SDK's `withSignal` returns
|
|
60
|
+
* `undefined` when the signal has already aborted or no transport is attached — the SDK's own
|
|
61
|
+
* handler then answers the typed cancellation; the handler's generic catch would otherwise
|
|
62
|
+
* re-report it as a tool error, so this wrapper throws the SDK's typed `AbortError` instead.
|
|
63
|
+
*/
|
|
64
|
+
export function withSignal(client, signal, fn) {
|
|
65
|
+
const c = client;
|
|
66
|
+
if (signal === undefined || typeof c.withSignal !== 'function')
|
|
67
|
+
return fn();
|
|
68
|
+
return c.withSignal(signal, fn);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=ops.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The per-tenant client pool (issue #91). */
|
|
2
|
+
import { type ClientPool } from 'node-autotask/pool';
|
|
3
|
+
import type { Logger } from '../../logger.js';
|
|
4
|
+
/** The process-lifetime bounded pool of the http transport (security D-1, ratified). */
|
|
5
|
+
export declare function createTenantPool(log: Logger): ClientPool;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The per-tenant client pool (issue #91). */
|
|
2
|
+
import { createClientPool } from 'node-autotask/pool';
|
|
3
|
+
/** The process-lifetime bounded pool of the http transport (security D-1, ratified). */
|
|
4
|
+
export function createTenantPool(log) {
|
|
5
|
+
const pool = createClientPool({ maxSize: 128 });
|
|
6
|
+
log.info('tenant client pool ready', { maxSize: 128 });
|
|
7
|
+
return pool;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=pool.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** HTTP per-request credential and scope resolution (issue #91). */
|
|
2
|
+
import type { ClientPool } from 'node-autotask/pool';
|
|
3
|
+
import type { Config } from '../../config.js';
|
|
4
|
+
import type { Logger } from '../../logger.js';
|
|
5
|
+
import type { HttpScopeResolver } from './types.js';
|
|
6
|
+
import { buildSdkBaseOptions } from './client.js';
|
|
7
|
+
/**
|
|
8
|
+
* Build the per-request scope resolver for the http transport.
|
|
9
|
+
*
|
|
10
|
+
* The effective policy (mirrors the security S2/C2.2 layering):
|
|
11
|
+
* - a caller-named origin is refused unless `AUTOTASK_ALLOWED_BASE_HOSTS` permits it;
|
|
12
|
+
* - allow-list unset + default origin set -> the default origin only is accepted;
|
|
13
|
+
* - neither set -> boot is refused unless the explicit lab-only `AUTOTASK_ALLOW_ANY_BASE_HOST`
|
|
14
|
+
* (config validation makes that mode reachable by typing it out, never by forgetting);
|
|
15
|
+
* - the default origin is operator truth: never checked against the allow-list, but it must
|
|
16
|
+
* itself pass the https + private-IP bounds (enforced at boot / at construction).
|
|
17
|
+
*
|
|
18
|
+
* Credentials are all-or-nothing: a request presenting 1 or 2 of the three identity headers is
|
|
19
|
+
* malformed and resolves to the `partial_credential` refusal (there is no per-field default
|
|
20
|
+
* fill); a request presenting none uses the server-held default tuple ONLY when it is set AND
|
|
21
|
+
* the request did not name a different origin (the default identity is only ever presented to
|
|
22
|
+
* the default origin — a caller cannot aim it at a host it controls).
|
|
23
|
+
*/
|
|
24
|
+
export declare function createHttpScopeResolver(config: Config, log: Logger, pool: ClientPool, baseOptions: ReturnType<typeof buildSdkBaseOptions>): HttpScopeResolver;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { serverCredentialDefaults } from '../../config.js';
|
|
2
|
+
import { baseHostAllowed, normalizeBaseUrl } from '../../base-hosts.js';
|
|
3
|
+
import { CREDENTIAL_LOAD_HINT, detectCredentialShape } from '../../credential.js';
|
|
4
|
+
import { buildSdkBaseOptions, buildPlaceholderClient } from './client.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build the per-request scope resolver for the http transport.
|
|
7
|
+
*
|
|
8
|
+
* The effective policy (mirrors the security S2/C2.2 layering):
|
|
9
|
+
* - a caller-named origin is refused unless `AUTOTASK_ALLOWED_BASE_HOSTS` permits it;
|
|
10
|
+
* - allow-list unset + default origin set -> the default origin only is accepted;
|
|
11
|
+
* - neither set -> boot is refused unless the explicit lab-only `AUTOTASK_ALLOW_ANY_BASE_HOST`
|
|
12
|
+
* (config validation makes that mode reachable by typing it out, never by forgetting);
|
|
13
|
+
* - the default origin is operator truth: never checked against the allow-list, but it must
|
|
14
|
+
* itself pass the https + private-IP bounds (enforced at boot / at construction).
|
|
15
|
+
*
|
|
16
|
+
* Credentials are all-or-nothing: a request presenting 1 or 2 of the three identity headers is
|
|
17
|
+
* malformed and resolves to the `partial_credential` refusal (there is no per-field default
|
|
18
|
+
* fill); a request presenting none uses the server-held default tuple ONLY when it is set AND
|
|
19
|
+
* the request did not name a different origin (the default identity is only ever presented to
|
|
20
|
+
* the default origin — a caller cannot aim it at a host it controls).
|
|
21
|
+
*/
|
|
22
|
+
export function createHttpScopeResolver(config, log, pool, baseOptions) {
|
|
23
|
+
const defaultBase = config.AUTOTASK_BASE_URL;
|
|
24
|
+
const defaultNormalized = defaultBase === undefined ? undefined : normalizeBaseUrl(defaultBase);
|
|
25
|
+
const defaultOrigin = defaultNormalized?.origin;
|
|
26
|
+
const placeholders = {
|
|
27
|
+
no_credential: buildPlaceholderClient(baseOptions),
|
|
28
|
+
partial_credential: buildPlaceholderClient(baseOptions),
|
|
29
|
+
no_origin: buildPlaceholderClient(baseOptions),
|
|
30
|
+
bad_origin: buildPlaceholderClient(baseOptions),
|
|
31
|
+
origin_not_allowed: buildPlaceholderClient(baseOptions),
|
|
32
|
+
// Unused: the malformed path builds its placeholder lazily (the strategy name carries the
|
|
33
|
+
// anomaly names, so there is one placeholder per anomaly set, not one for the kind).
|
|
34
|
+
malformed_credential: buildPlaceholderClient(baseOptions),
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The malformed-credential placeholders (issue #4b): one per anomaly SET, built lazily and cached,
|
|
38
|
+
* because the strategy name carries the anomaly names and the anomaly vocabulary is small and
|
|
39
|
+
* bounded — a fresh client per malformed request would let a caller allocate one per request. The
|
|
40
|
+
* refusal itself is decided by `tools/results.ts` from the anomaly names in the strategy name.
|
|
41
|
+
*/
|
|
42
|
+
const malformedPlaceholders = new Map();
|
|
43
|
+
/**
|
|
44
|
+
* The pre-dispatch credential shape gate, on the values THIS request will present.
|
|
45
|
+
*
|
|
46
|
+
* It runs before any client is constructed (so before any dial, and before the origin checks —
|
|
47
|
+
* a malformed credential is refused whatever origin it named). Both the per-request headers and
|
|
48
|
+
* the server-held defaults win the same treatment: whichever value would be presented is the one
|
|
49
|
+
* judged.
|
|
50
|
+
*/
|
|
51
|
+
function refuseMalformedCredentials(values) {
|
|
52
|
+
const anomalies = new Set();
|
|
53
|
+
for (const [, value] of values) {
|
|
54
|
+
if (value === undefined || value.trim() === '')
|
|
55
|
+
continue;
|
|
56
|
+
for (const anomaly of detectCredentialShape(value))
|
|
57
|
+
anomalies.add(anomaly);
|
|
58
|
+
}
|
|
59
|
+
if (anomalies.size === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const list = [...anomalies];
|
|
62
|
+
log.warn('refused a malformed Autotask credential before dialing', {
|
|
63
|
+
source: 'request headers (or the server-held defaults)',
|
|
64
|
+
anomalies: list,
|
|
65
|
+
hint: CREDENTIAL_LOAD_HINT,
|
|
66
|
+
});
|
|
67
|
+
const id = list.join(',');
|
|
68
|
+
let client = malformedPlaceholders.get(id);
|
|
69
|
+
if (client === undefined) {
|
|
70
|
+
client = { __malformedAnomalies: list };
|
|
71
|
+
malformedPlaceholders.set(id, client);
|
|
72
|
+
}
|
|
73
|
+
return { client, refusal: { kind: 'malformed_credential', anomalies: list } };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
resolve(scope) {
|
|
77
|
+
// ---- origin ----
|
|
78
|
+
const headerBase = scope.baseUrl;
|
|
79
|
+
const headerNormalized = headerBase === undefined ? undefined : normalizeBaseUrl(headerBase);
|
|
80
|
+
const callerNamed = headerBase !== undefined && (headerNormalized === undefined || headerNormalized.origin !== defaultOrigin);
|
|
81
|
+
const base = headerBase ?? defaultBase;
|
|
82
|
+
const baseNormalized = base === undefined ? undefined : (headerBase !== undefined ? headerNormalized : defaultNormalized);
|
|
83
|
+
// ---- pre-dispatch credential SHAPE gate (issue #4b) ----
|
|
84
|
+
// Runs first: a malformed value is refused before any client is constructed and before the
|
|
85
|
+
// origin policy is consulted, because the shape is decidable without a dial and a malformed
|
|
86
|
+
// credential is refused whatever origin it named. Both sources are judged: the request's own
|
|
87
|
+
// headers, and the server-held defaults a header-less request would run as.
|
|
88
|
+
const defaultsForShape = serverCredentialDefaults(config);
|
|
89
|
+
const malformed = refuseMalformedCredentials([
|
|
90
|
+
['X-Autotask-Api-Integration-Code', scope.apiIntegrationCode ?? defaultsForShape?.apiIntegrationCode],
|
|
91
|
+
['X-Autotask-UserName', scope.userName ?? defaultsForShape?.userName],
|
|
92
|
+
['X-Autotask-Secret', scope.secret ?? defaultsForShape?.secret],
|
|
93
|
+
['X-Autotask-Impersonation-Resource-Id', scope.impersonationResourceId ?? defaultsForShape?.impersonationResourceId],
|
|
94
|
+
]);
|
|
95
|
+
if (malformed !== undefined)
|
|
96
|
+
return malformed;
|
|
97
|
+
// ---- credentials (all-or-nothing) ----
|
|
98
|
+
const present = [scope.apiIntegrationCode, scope.userName, scope.secret].filter((v) => v !== undefined).length;
|
|
99
|
+
let creds;
|
|
100
|
+
if (present === 3) {
|
|
101
|
+
creds = {
|
|
102
|
+
apiIntegrationCode: scope.apiIntegrationCode,
|
|
103
|
+
userName: scope.userName,
|
|
104
|
+
secret: scope.secret,
|
|
105
|
+
...(scope.impersonationResourceId !== undefined ? { impersonationResourceId: scope.impersonationResourceId } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
else if (present > 0) {
|
|
109
|
+
// A partial tuple is malformed: fail closed, no default fill.
|
|
110
|
+
log.warn('request refused: partial credential tuple', { presented: present });
|
|
111
|
+
return { client: placeholders.partial_credential, refusal: { kind: 'partial_credential' } };
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const defaults = serverCredentialDefaults(config);
|
|
115
|
+
// The default identity is only ever presented to the DEFAULT origin.
|
|
116
|
+
if (defaults !== undefined && !callerNamed) {
|
|
117
|
+
creds = defaults;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ---- refusals, in precedence order ----
|
|
121
|
+
if (creds === undefined) {
|
|
122
|
+
return { client: placeholders.no_credential, refusal: { kind: 'no_credential' } };
|
|
123
|
+
}
|
|
124
|
+
if (base === undefined || baseNormalized === undefined) {
|
|
125
|
+
const kind = headerBase !== undefined && headerNormalized === undefined ? 'bad_origin' : 'no_origin';
|
|
126
|
+
return { client: placeholders[kind], refusal: { kind } };
|
|
127
|
+
}
|
|
128
|
+
if (callerNamed) {
|
|
129
|
+
const allowed = config.AUTOTASK_ALLOWED_BASE_HOSTS === undefined
|
|
130
|
+
? config.AUTOTASK_ALLOW_ANY_BASE_HOST // unset list + no default origin: only the explicit lab opt-in admits
|
|
131
|
+
: baseHostAllowed(headerBase, config.AUTOTASK_ALLOWED_BASE_HOSTS);
|
|
132
|
+
if (!allowed) {
|
|
133
|
+
// The name of the policy is the only diagnostic: the refused value is never echoed.
|
|
134
|
+
log.warn('request refused: caller-named origin not allowed', { allowAny: config.AUTOTASK_ALLOW_ANY_BASE_HOST, impersonated: creds.impersonationResourceId !== undefined });
|
|
135
|
+
return { client: placeholders.origin_not_allowed, refusal: { kind: 'origin_not_allowed' } };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ---- the pooled tenant client (D-1: the single sanctioned in-process state) ----
|
|
139
|
+
// Security S2/C2.2 layering (FT-1): the server allow-list is what ADMITS the caller
|
|
140
|
+
// origin; the SDK hostPolicy adds only the literal-IP floor. The client's allowedOrigins
|
|
141
|
+
// must therefore cover every origin the server admits for THIS request — the caller's own
|
|
142
|
+
// (allow-listed) origin plus the deployment default when set. Deriving it from the
|
|
143
|
+
// deployment-wide default alone 500'd an allow-listed NON-default caller origin at client
|
|
144
|
+
// construction (HostPolicyDeniedError out of the per-request factory).
|
|
145
|
+
const allowedOrigins = [baseNormalized.origin];
|
|
146
|
+
if (defaultOrigin !== undefined && defaultOrigin !== baseNormalized.origin)
|
|
147
|
+
allowedOrigins.push(defaultOrigin);
|
|
148
|
+
const poolConfig = {
|
|
149
|
+
apiIntegrationCode: creds.apiIntegrationCode,
|
|
150
|
+
userName: creds.userName,
|
|
151
|
+
secret: creds.secret,
|
|
152
|
+
...(creds.impersonationResourceId !== undefined ? { impersonationResourceId: creds.impersonationResourceId } : {}),
|
|
153
|
+
baseUrl: baseNormalized.baseUrl,
|
|
154
|
+
...baseOptions,
|
|
155
|
+
hostPolicy: {
|
|
156
|
+
...(baseOptions.hostPolicy ?? {}),
|
|
157
|
+
allowedOrigins,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
const client = pool.get(poolConfig);
|
|
161
|
+
return { client, origin: baseNormalized.origin, poolConfig };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=scope.js.map
|