@shieldaai/agent-map 0.1.0-alpha.3 → 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 (96) hide show
  1. package/README.md +652 -272
  2. package/dist/assets.js +16 -26
  3. package/dist/boundary-flags.d.ts +73 -0
  4. package/dist/boundary-flags.js +352 -0
  5. package/dist/boundary.d.ts +18 -3
  6. package/dist/boundary.js +71 -21
  7. package/dist/cli-entry.d.ts +11 -0
  8. package/dist/cli-entry.js +48 -0
  9. package/dist/cli.js +40 -43
  10. package/dist/common.d.ts +34 -0
  11. package/dist/common.js +94 -0
  12. package/dist/http.js +78 -51
  13. package/dist/identity-cli.js +93 -46
  14. package/dist/identity-http.js +226 -94
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/mcp.js +40 -5
  18. package/dist/oidc.d.ts +5 -0
  19. package/dist/oidc.js +121 -38
  20. package/dist/policy-reader.d.ts +56 -3
  21. package/dist/policy-reader.js +823 -110
  22. package/dist/projection-fields.d.ts +4 -0
  23. package/dist/projection-fields.js +144 -0
  24. package/dist/service/changes.d.ts +5 -0
  25. package/dist/service/changes.js +255 -0
  26. package/dist/service/context.d.ts +33 -0
  27. package/dist/service/context.js +2 -0
  28. package/dist/service/discovery.d.ts +6 -0
  29. package/dist/service/discovery.js +31 -0
  30. package/dist/service/explain.d.ts +12 -0
  31. package/dist/service/explain.js +126 -0
  32. package/dist/service/flags.d.ts +20 -0
  33. package/dist/service/flags.js +70 -0
  34. package/dist/service/inventory.d.ts +4 -0
  35. package/dist/service/inventory.js +62 -0
  36. package/dist/service/json.d.ts +8 -0
  37. package/dist/service/json.js +7 -0
  38. package/dist/service/operations/discovery.d.ts +5 -0
  39. package/dist/service/operations/discovery.js +90 -0
  40. package/dist/service/operations/explain-access.d.ts +3 -0
  41. package/dist/service/operations/explain-access.js +25 -0
  42. package/dist/service/operations/find-agents-with-access.d.ts +3 -0
  43. package/dist/service/operations/find-agents-with-access.js +120 -0
  44. package/dist/service/operations/get-agent-access.d.ts +3 -0
  45. package/dist/service/operations/get-agent-access.js +75 -0
  46. package/dist/service/operations/get-agent-inventory.d.ts +3 -0
  47. package/dist/service/operations/get-agent-inventory.js +42 -0
  48. package/dist/service/operations/get-changes.d.ts +3 -0
  49. package/dist/service/operations/get-changes.js +11 -0
  50. package/dist/service/operations/get-inventory-health.d.ts +3 -0
  51. package/dist/service/operations/get-inventory-health.js +81 -0
  52. package/dist/service/operations/get-resource-access.d.ts +3 -0
  53. package/dist/service/operations/get-resource-access.js +48 -0
  54. package/dist/service/operations/list-agents.d.ts +3 -0
  55. package/dist/service/operations/list-agents.js +92 -0
  56. package/dist/service/operations/list-owners-resources.d.ts +5 -0
  57. package/dist/service/operations/list-owners-resources.js +69 -0
  58. package/dist/service/ordering.d.ts +9 -0
  59. package/dist/service/ordering.js +47 -0
  60. package/dist/service/outside-guard.d.ts +68 -0
  61. package/dist/service/outside-guard.js +313 -0
  62. package/dist/service/policy.d.ts +26 -0
  63. package/dist/service/policy.js +299 -0
  64. package/dist/service/schemas.d.ts +16 -0
  65. package/dist/service/schemas.js +192 -0
  66. package/dist/service/summaries.d.ts +32 -0
  67. package/dist/service/summaries.js +247 -0
  68. package/dist/service/work.d.ts +33 -0
  69. package/dist/service/work.js +116 -0
  70. package/dist/service.d.ts +5 -5
  71. package/dist/service.js +110 -645
  72. package/dist/store/ancestors.d.ts +9 -0
  73. package/dist/store/ancestors.js +86 -0
  74. package/dist/store/discovery.d.ts +8 -0
  75. package/dist/store/discovery.js +288 -0
  76. package/dist/store/evidence.d.ts +12 -0
  77. package/dist/store/evidence.js +90 -0
  78. package/dist/store/identity-scope.d.ts +9 -0
  79. package/dist/store/identity-scope.js +71 -0
  80. package/dist/store/nodes.d.ts +24 -0
  81. package/dist/store/nodes.js +237 -0
  82. package/dist/store/projection.d.ts +19 -0
  83. package/dist/store/projection.js +242 -0
  84. package/dist/store/queries.d.ts +48 -0
  85. package/dist/store/queries.js +302 -0
  86. package/dist/store/read.d.ts +81 -0
  87. package/dist/store/read.js +135 -0
  88. package/dist/store/selection.d.ts +3 -0
  89. package/dist/store/selection.js +102 -0
  90. package/dist/store.d.ts +15 -0
  91. package/dist/store.js +102 -395
  92. package/dist/transport.d.ts +42 -2
  93. package/dist/transport.js +144 -16
  94. package/dist/types.d.ts +184 -4
  95. package/dist/types.js +52 -11
  96. package/package.json +16 -3
package/dist/cli-entry.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { realpathSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { pathToFileURL } from 'node:url';
4
+ import { MapQueryError } from './types.js';
5
+ /** Optional `neo4j` configuration keys passed to the store as its per-read limits. */
6
+ export const STORE_LIMITS = ['maxNodes', 'maxEdges', 'maxBytes'];
7
+ export const PACKAGE_VERSION = createRequire(import.meta.url)('../package.json').version;
3
8
  /** npm's POSIX executable is a symlink. Node resolves it when loading the module. */
4
9
  export function isCliEntry(meta) {
5
10
  if (typeof meta.main === 'boolean')
@@ -16,3 +21,46 @@ export function isCliEntry(meta) {
16
21
  return false;
17
22
  }
18
23
  }
24
+ // One fixed line per cause. Nothing from the error itself is printed: its message can hold a
25
+ // path, an argument or a configuration value.
26
+ const STARTUP_HINTS = Object.freeze({
27
+ MAP_ARGUMENT_INVALID: 'an argument is unknown or incomplete, or --config is missing.',
28
+ MAP_CONFIG_NOT_FOUND: 'the configuration file, or a file it names, does not exist.',
29
+ MAP_CONFIG_UNREADABLE: 'the configuration file, or a file it names, cannot be opened; check its type, owner and permissions.',
30
+ MAP_CONFIG_INVALID: 'the configuration is not accepted; check that it is strict JSON with known field names and valid values, and that each file is a regular file owned by the service user and not writable by others.',
31
+ MAP_CREDENTIAL_REQUIRED: 'an environment variable the configuration names is unset or empty.',
32
+ MAP_SCOPE_INVALID: 'a credential or binding scope is not valid.',
33
+ MAP_LISTEN_FAILED: 'the configured address and port could not be opened; another process may be using the port.',
34
+ MAP_IDENTITY_INVALID: 'the identity provider metadata does not match the configuration (issuer, RS256, S256 PKCE, client authentication or endpoint origins).',
35
+ MAP_IDENTITY_UNAVAILABLE: 'the identity provider did not answer within the deadline.',
36
+ MAP_UNAVAILABLE: 'a required service or capability is unavailable.',
37
+ MAP_START_FAILED: 'unexpected failure during startup.',
38
+ });
39
+ /** The specific safe code for a startup failure, and whether the usage line helps. */
40
+ export function startupFailure(error) {
41
+ const raw = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
42
+ const syscall = error && typeof error === 'object' && 'syscall' in error ? String(error.syscall) : '';
43
+ let code = 'MAP_START_FAILED';
44
+ if (error instanceof MapQueryError) {
45
+ // A configuration file that is not strict JSON, or too large, is a configuration fault.
46
+ code = ['MAP_REQUEST_INVALID', 'MAP_REQUEST_TOO_LARGE'].includes(raw) ? 'MAP_CONFIG_INVALID' : raw;
47
+ }
48
+ else if (raw.startsWith('ERR_PARSE_ARGS'))
49
+ code = 'MAP_ARGUMENT_INVALID';
50
+ else if (syscall === 'listen' || raw === 'EADDRINUSE' || raw === 'EADDRNOTAVAIL')
51
+ code = 'MAP_LISTEN_FAILED';
52
+ else if (raw === 'ENOENT' || raw === 'ENOTDIR')
53
+ code = 'MAP_CONFIG_NOT_FOUND';
54
+ else if (['EACCES', 'EPERM', 'ELOOP', 'EISDIR', 'ENXIO', 'EMFILE', 'ENFILE'].includes(raw))
55
+ code = 'MAP_CONFIG_UNREADABLE';
56
+ if (!Object.hasOwn(STARTUP_HINTS, code))
57
+ code = 'MAP_START_FAILED';
58
+ return { code, hint: STARTUP_HINTS[code], usage: code === 'MAP_ARGUMENT_INVALID' };
59
+ }
60
+ /** Prints `MAP_START_FAILED <code>: <hint>` (and the usage for an argument error) and sets the exit code. */
61
+ export function reportStartupFailure(error, usage) {
62
+ const failure = startupFailure(error);
63
+ const cause = failure.code === 'MAP_START_FAILED' ? '' : ` ${failure.code}`;
64
+ process.stderr.write(`MAP_START_FAILED${cause}: ${failure.hint}\n${failure.usage ? `Usage: ${usage}\n` : ''}`);
65
+ process.exitCode = 1;
66
+ }
package/dist/cli.js CHANGED
@@ -1,59 +1,43 @@
1
1
  #!/usr/bin/env node
2
- import { constants } from 'node:fs';
3
- import { open } from 'node:fs/promises';
4
2
  import { resolve } from 'node:path';
5
3
  import { parseArgs } from 'node:util';
6
- import { isCliEntry } from './cli-entry.js';
4
+ import { PACKAGE_VERSION, STORE_LIMITS, isCliEntry, reportStartupFailure } from './cli-entry.js';
7
5
  import { createNeo4jMapStore } from './store.js';
8
6
  import { MapService } from './service.js';
9
7
  import { startMapServer } from './http.js';
10
8
  import { validateReadScope } from './types.js';
11
- import { fields, fail, integer, parseRequest, trustedHostFile } from './boundary.js';
9
+ import { fail, integer } from './common.js';
10
+ import { fields, parseRequest, readHostFile } from './boundary.js';
12
11
  function environmentName(value) {
13
12
  if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value))
14
13
  fail('MAP_CONFIG_INVALID');
15
14
  return value;
16
15
  }
17
- /** Keep the type check and bounded read on one nonblocking descriptor. */
18
- async function configBytes(path) {
19
- if (typeof constants.O_NONBLOCK !== 'number')
20
- fail('MAP_CONFIG_INVALID');
21
- const maximum = 128 * 1024;
22
- const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
23
- try {
24
- const info = await file.stat();
25
- if (!trustedHostFile(info) || info.size > maximum)
26
- fail('MAP_CONFIG_INVALID');
27
- // The file may grow after stat; never use an unbounded readFile here.
28
- const data = Buffer.alloc(maximum + 1);
29
- let bytes = 0;
30
- while (bytes < data.length) {
31
- const read = await file.read(data, bytes, data.length - bytes, null);
32
- if (!read.bytesRead)
33
- break;
34
- bytes += read.bytesRead;
35
- }
36
- if (bytes > maximum)
37
- fail('MAP_CONFIG_INVALID');
38
- return data.subarray(0, bytes);
39
- }
40
- finally {
41
- await file.close();
42
- }
43
- }
16
+ /** The reference launcher's configuration: a regular file of this user or root, not writable by others. */
17
+ const CONFIG_FILE = { noFollow: false, owner: 'service-or-root', singleLink: false, private: false };
18
+ const USAGE = 'agent-guard-map --config HOST_CONFIG.json | --help | --version';
44
19
  /** Credential values stay in the environment and are never printed by this launcher. */
45
20
  export async function runMapCli(argv = process.argv.slice(2)) {
46
- const { values } = parseArgs({ args: argv, options: { config: { type: 'string' }, help: { type: 'boolean' } }, strict: true });
21
+ const { values } = parseArgs({
22
+ args: argv,
23
+ options: { config: { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
24
+ strict: true,
25
+ });
47
26
  if (values.help) {
48
- process.stdout.write('agent-guard-map --config HOST_CONFIG.json\n');
27
+ process.stdout.write(`${USAGE}\n`);
28
+ return;
29
+ }
30
+ if (values.version) {
31
+ process.stdout.write(`${PACKAGE_VERSION}\n`);
49
32
  return;
50
33
  }
51
34
  if (!values.config)
52
- fail('MAP_CONFIG_INVALID');
35
+ fail('MAP_ARGUMENT_INVALID');
53
36
  const path = resolve(values.config);
54
- const config = fields(parseRequest(await configBytes(path), 128 * 1024), ['neo4j', 'credentials', 'port', 'viewerDirectory'], ['neo4j', 'credentials']);
55
- const database = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database'], ['uri', 'username', 'passwordEnv']);
56
- if (typeof database.uri !== 'string' || typeof database.username !== 'string' ||
37
+ const config = fields(parseRequest(await readHostFile(path, 128 * 1024, CONFIG_FILE), 128 * 1024), ['neo4j', 'credentials', 'port', 'viewerDirectory'], ['neo4j', 'credentials']);
38
+ const database = fields(config.neo4j, ['uri', 'username', 'passwordEnv', 'database', ...STORE_LIMITS], ['uri', 'username', 'passwordEnv']);
39
+ if (typeof database.uri !== 'string' ||
40
+ typeof database.username !== 'string' ||
57
41
  (database.database !== undefined && typeof database.database !== 'string'))
58
42
  fail('MAP_CONFIG_INVALID');
59
43
  const password = process.env[environmentName(database.passwordEnv)];
@@ -70,15 +54,28 @@ export async function runMapCli(argv = process.argv.slice(2)) {
70
54
  });
71
55
  if (config.viewerDirectory !== undefined && typeof config.viewerDirectory !== 'string')
72
56
  fail('MAP_CONFIG_INVALID');
73
- const store = createNeo4jMapStore({ uri: database.uri, username: database.username, password,
74
- ...(database.database === undefined ? {} : { database: database.database }) });
57
+ const store = createNeo4jMapStore({
58
+ uri: database.uri,
59
+ username: database.username,
60
+ password,
61
+ ...(database.database === undefined ? {} : { database: database.database }),
62
+ // Optional read limits; the store checks their type and hard maximum.
63
+ ...Object.fromEntries(STORE_LIMITS.filter(key => database[key] !== undefined).map(key => [key, database[key]])),
64
+ });
75
65
  try {
76
- const server = await startMapServer({ service: new MapService(store), credentials,
66
+ const server = await startMapServer({
67
+ service: new MapService(store),
68
+ credentials,
77
69
  port: integer(config.port, 8789, 0, 65535),
78
- ...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(config.viewerDirectory) }) });
70
+ ...(config.viewerDirectory === undefined ? {} : { viewerDirectory: resolve(config.viewerDirectory) }),
71
+ });
79
72
  process.stdout.write(`${JSON.stringify({ url: server.url, mode: 'read-only' })}\n`);
80
73
  await new Promise(done => {
81
- const stop = () => { process.off('SIGINT', stop); process.off('SIGTERM', stop); done(); };
74
+ const stop = () => {
75
+ process.off('SIGINT', stop);
76
+ process.off('SIGTERM', stop);
77
+ done();
78
+ };
82
79
  process.once('SIGINT', stop);
83
80
  process.once('SIGTERM', stop);
84
81
  });
@@ -89,5 +86,5 @@ export async function runMapCli(argv = process.argv.slice(2)) {
89
86
  }
90
87
  }
91
88
  if (isCliEntry(import.meta)) {
92
- void runMapCli().catch(() => { process.stderr.write('MAP_START_FAILED\n'); process.exitCode = 1; });
89
+ void runMapCli().catch(error => reportStartupFailure(error, USAGE));
93
90
  }
@@ -0,0 +1,34 @@
1
+ import { type MapEdge, type MapIdentityScope, type MapNode } from './types.js';
2
+ export declare function fail(code: string): never;
3
+ /** A safe integer option within `[min, max]`, or `fallback` when it is not set. */
4
+ export declare function integer(value: unknown, fallback: number, min: number, max: number): number;
5
+ /** Unicode code-point order (the order Python sorts strings in), independent of host locale. */
6
+ export declare function compareCodePoints(a: string, b: string): number;
7
+ /**
8
+ * The bucket and object key of an S3 ARN, or null when it is not one. `key` is undefined for
9
+ * a bare bucket ARN and may be empty for one that ends in a slash.
10
+ */
11
+ export declare function parseS3Arn(arn: unknown): {
12
+ readonly bucket: string;
13
+ readonly key: string | undefined;
14
+ } | null;
15
+ /** The bucket name of an S3 bucket or object ARN, or null. */
16
+ export declare const s3BucketName: (arn: unknown) => string | null;
17
+ export declare const LIST_SHORTENED = "bucket_list_shortened";
18
+ /**
19
+ * An identity-scope record with its bucket list cut to `keep` entries. Only the list changes:
20
+ * the status came from the full comparison and stands.
21
+ */
22
+ export declare function shortenScopeList(record: MapIdentityScope, keep: number): MapIdentityScope;
23
+ /** Adjacency over one projection's nodes and edges, in projection order. */
24
+ export interface GraphIndex {
25
+ readonly nodes: Map<string, MapNode>;
26
+ /** Positions of each node ID in the node list, so subgraphs keep projection order. */
27
+ readonly nodePositions: Map<string, number[]>;
28
+ /** Positions of each source's edges in the edge list. */
29
+ readonly edgesFrom: Map<string, number[]>;
30
+ readonly outgoing: Map<string, Map<MapEdge['kind'], string[]>>;
31
+ readonly incoming: Map<string, Map<MapEdge['kind'], string[]>>;
32
+ }
33
+ /** Builds the index in one pass; callers account for the work themselves. */
34
+ export declare function buildGraphIndex(nodes: readonly MapNode[], edges: readonly MapEdge[]): GraphIndex;
package/dist/common.js ADDED
@@ -0,0 +1,94 @@
1
+ import { MapQueryError } from './types.js';
2
+ // Helpers shared by the service, the store and the HTTP boundaries. Nothing in this file touches
3
+ // the network, the file system or a Node-only module. The service as a whole is not free of
4
+ // them: `service/work.ts` and `service/policy.ts` import `node:buffer`, and the JSON helpers of
5
+ // `@shieldaai/agent-guard/internal` import `node:buffer`, `node:crypto` and `node:util/types`.
6
+ // A browser bundle of `@shieldaai/agent-map/service` must supply stand-ins for those three
7
+ // (the viewer build does).
8
+ export function fail(code) {
9
+ throw new MapQueryError(code);
10
+ }
11
+ /** A safe integer option within `[min, max]`, or `fallback` when it is not set. */
12
+ export function integer(value, fallback, min, max) {
13
+ const result = value === undefined ? fallback : value;
14
+ if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < min || result > max)
15
+ fail('MAP_CONFIG_INVALID');
16
+ return result;
17
+ }
18
+ /** Unicode code-point order (the order Python sorts strings in), independent of host locale. */
19
+ export function compareCodePoints(a, b) {
20
+ let left = 0, right = 0;
21
+ while (left < a.length && right < b.length) {
22
+ const ac = a.codePointAt(left), bc = b.codePointAt(right);
23
+ if (ac !== bc)
24
+ return ac - bc;
25
+ left += ac > 0xffff ? 2 : 1;
26
+ right += bc > 0xffff ? 2 : 1;
27
+ }
28
+ return a.length - left - (b.length - right);
29
+ }
30
+ const S3_ARN = /^arn:aws:s3:::([a-z0-9][a-z0-9.-]{1,61}[a-z0-9])(?:\/(.*))?$/;
31
+ /**
32
+ * The bucket and object key of an S3 ARN, or null when it is not one. `key` is undefined for
33
+ * a bare bucket ARN and may be empty for one that ends in a slash.
34
+ */
35
+ export function parseS3Arn(arn) {
36
+ const match = S3_ARN.exec(typeof arn === 'string' ? arn : '');
37
+ return match ? { bucket: match[1], key: match[2] } : null;
38
+ }
39
+ /** The bucket name of an S3 bucket or object ARN, or null. */
40
+ export const s3BucketName = (arn) => parseS3Arn(arn)?.bucket ?? null;
41
+ export const LIST_SHORTENED = 'bucket_list_shortened';
42
+ /**
43
+ * An identity-scope record with its bucket list cut to `keep` entries. Only the list changes:
44
+ * the status came from the full comparison and stands.
45
+ */
46
+ export function shortenScopeList(record, keep) {
47
+ if (record.extraResources.length <= keep)
48
+ return record;
49
+ return {
50
+ ...record,
51
+ extraResources: record.extraResources.slice(0, keep),
52
+ truncated: true,
53
+ notes: record.notes?.includes(LIST_SHORTENED) ? record.notes : [...(record.notes ?? []), LIST_SHORTENED],
54
+ };
55
+ }
56
+ /** Builds the index in one pass; callers account for the work themselves. */
57
+ export function buildGraphIndex(nodes, edges) {
58
+ const result = {
59
+ nodes: new Map(),
60
+ nodePositions: new Map(),
61
+ edgesFrom: new Map(),
62
+ outgoing: new Map(),
63
+ incoming: new Map(),
64
+ };
65
+ const push = (map, key, position) => {
66
+ const list = map.get(key);
67
+ if (list)
68
+ list.push(position);
69
+ else
70
+ map.set(key, [position]);
71
+ };
72
+ nodes.forEach((node, position) => {
73
+ result.nodes.set(node.id, node);
74
+ push(result.nodePositions, node.id, position);
75
+ });
76
+ const add = (map, from, kind, to) => {
77
+ let kinds = map.get(from);
78
+ if (!kinds) {
79
+ kinds = new Map();
80
+ map.set(from, kinds);
81
+ }
82
+ const items = kinds.get(kind);
83
+ if (items)
84
+ items.push(to);
85
+ else
86
+ kinds.set(kind, [to]);
87
+ };
88
+ edges.forEach((edge, position) => {
89
+ push(result.edgesFrom, edge.source, position);
90
+ add(result.outgoing, edge.source, edge.kind, edge.target);
91
+ add(result.incoming, edge.target, edge.kind, edge.source);
92
+ });
93
+ return result;
94
+ }
package/dist/http.js CHANGED
@@ -1,19 +1,31 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
- import { immutableJson } from '@shieldaai/agent-guard';
3
+ import { immutableJson } from '@shieldaai/agent-guard/internal';
4
4
  import { MapService } from './service.js';
5
5
  import { validateReadScope } from './types.js';
6
6
  import { createMapMcpHandler } from './mcp.js';
7
- import { fail, fields, integer, parseRequest } from './boundary.js';
8
- import { headers, errorResponse, safeError, count, consume, readBody } from './transport.js';
7
+ import { fail, integer } from './common.js';
8
+ import { fields } from './boundary.js';
9
+ import { headers, errorResponse, respondError, count, consume, queryEnvelope, rateLimit, readJsonBody, timeoutStatus, refuseOtherTraffic, sendAsset, } from './transport.js';
9
10
  import { loadMapAssets } from './assets.js';
10
11
  const TOKEN = /^agm_v1\.[A-Za-z0-9_-]{43}$/;
11
12
  const hash = (text) => createHash('sha256').update(text).digest();
12
- export function createMapToken() { return `agm_v1.${randomBytes(32).toString('base64url')}`; }
13
+ export function createMapToken() {
14
+ return `agm_v1.${randomBytes(32).toString('base64url')}`;
15
+ }
13
16
  /** Loopback pilot server. Remote serving requires a trusted, verified TLS reverse proxy. */
14
17
  export async function startMapServer(options) {
15
- const config = fields(options, ['service', 'credentials', 'viewerDirectory', 'port', 'maxRequestBytes',
16
- 'maxResponseBytes', 'requestTimeoutMs', 'maxConcurrentRequests', 'rateLimit'], ['service', 'credentials']);
18
+ const config = fields(options, [
19
+ 'service',
20
+ 'credentials',
21
+ 'viewerDirectory',
22
+ 'port',
23
+ 'maxRequestBytes',
24
+ 'maxResponseBytes',
25
+ 'requestTimeoutMs',
26
+ 'maxConcurrentRequests',
27
+ 'rateLimit',
28
+ ], ['service', 'credentials']);
17
29
  if (!(config.service instanceof MapService))
18
30
  fail('MAP_CONFIG_INVALID');
19
31
  const service = config.service;
@@ -22,8 +34,7 @@ export async function startMapServer(options) {
22
34
  const responseLimit = integer(config.maxResponseBytes, 2 * 1024 * 1024, 128, 2 * 1024 * 1024);
23
35
  const timeout = integer(config.requestTimeoutMs, 10_000, 10, 30_000);
24
36
  const concurrency = integer(config.maxConcurrentRequests, 4, 1, 32);
25
- const rate = config.rateLimit === undefined ? {} : fields(config.rateLimit, ['maxRequests', 'windowMs'], ['maxRequests', 'windowMs']);
26
- const maxRequests = integer(rate.maxRequests, 120, 1, 10_000), windowMs = integer(rate.windowMs, 60_000, 10, 60_000);
37
+ const { maxRequests, windowMs } = rateLimit(config.rateLimit, 120);
27
38
  let copied;
28
39
  try {
29
40
  copied = immutableJson(config.credentials, 8, 100_000, 1024 * 1024);
@@ -49,14 +60,16 @@ export async function startMapServer(options) {
49
60
  const socketTimers = new WeakMap();
50
61
  let active = 0, closing = false, host = '';
51
62
  const globalBucket = { since: performance.now(), count: 0 };
52
- const server = createServer({ maxHeaderSize: 8192, headersTimeout: timeout, requestTimeout: timeout, keepAliveTimeout: 1 }, (request, response) => { void handle(request, response); });
63
+ const server = createServer({ maxHeaderSize: 8192, headersTimeout: timeout, requestTimeout: timeout, keepAliveTimeout: 1 }, (request, response) => {
64
+ void handle(request, response);
65
+ });
53
66
  server.maxConnections = 64;
54
67
  server.maxHeadersCount = 0;
55
68
  async function handle(request, response) {
56
69
  request.on('error', () => { });
57
70
  response.on('error', () => { });
58
71
  // Once headers arrive, leave a short bounded flush window after the request
59
- // deadline. A connection timer at the same instant would erase the 408.
72
+ // deadline. A connection timer at the same instant would erase the 408 or 504.
60
73
  clearTimeout(socketTimers.get(request.socket));
61
74
  const socketTimer = setTimeout(() => request.socket.destroy(), timeout + 250);
62
75
  socketTimer.unref();
@@ -64,9 +77,14 @@ export async function startMapServer(options) {
64
77
  const started = performance.now();
65
78
  const abort = new AbortController();
66
79
  let admitted = false;
80
+ // Whether the whole request has arrived (a GET has no body to wait for): a timeout after
81
+ // that is the service's (504), not the client's (408).
82
+ let received = request.method === 'GET';
67
83
  let timer;
68
- const check = () => { if (abort.signal.aborted || performance.now() - started > timeout)
69
- fail('MAP_TIMEOUT'); };
84
+ const check = () => {
85
+ if (abort.signal.aborted || performance.now() - started > timeout)
86
+ fail('MAP_TIMEOUT');
87
+ };
70
88
  try {
71
89
  consume(globalBucket, Math.min(maxRequests * credentials.length, 10_000), windowMs);
72
90
  if (closing || active >= concurrency)
@@ -74,15 +92,22 @@ export async function startMapServer(options) {
74
92
  active++;
75
93
  admitted = true;
76
94
  aborts.add(abort);
77
- timer = setTimeout(() => { abort.abort(); errorResponse(response, 'MAP_TIMEOUT'); }, timeout);
95
+ timer = setTimeout(() => {
96
+ abort.abort();
97
+ errorResponse(response, 'MAP_TIMEOUT', timeoutStatus(received));
98
+ }, timeout);
78
99
  timer.unref();
79
- if (count(request, 'host') !== 1 || request.headers.host !== host || request.headers['proxy-authorization'] !== undefined)
100
+ if (count(request, 'host') !== 1 ||
101
+ request.headers.host !== host ||
102
+ request.headers['proxy-authorization'] !== undefined)
80
103
  fail('MAP_REQUEST_DENIED');
81
104
  const origin = request.headers.origin;
82
- if (count(request, 'origin') > 1 || (origin !== undefined && origin !== `http://${host}`) ||
83
- (request.headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(request.headers['sec-fetch-site'])))
105
+ if (count(request, 'origin') > 1 ||
106
+ (origin !== undefined && origin !== `http://${host}`) ||
107
+ (request.headers['sec-fetch-site'] !== undefined &&
108
+ !['same-origin', 'none'].includes(request.headers['sec-fetch-site'])))
84
109
  fail('MAP_REQUEST_DENIED');
85
- const path = request.url === '/' ? '/index.html' : request.url ?? '';
110
+ const path = request.url === '/' ? '/index.html' : (request.url ?? '');
86
111
  if (request.method === 'GET' && path === '/auth/session') {
87
112
  const body = Buffer.from(JSON.stringify({ mode: 'bearer' }));
88
113
  headers(response, body.length);
@@ -92,9 +117,7 @@ export async function startMapServer(options) {
92
117
  }
93
118
  const asset = assets.get(path);
94
119
  if (request.method === 'GET' && asset) {
95
- headers(response, asset.body.length, asset.mime);
96
- response.writeHead(200);
97
- response.end(asset.body);
120
+ sendAsset(response, asset);
98
121
  return;
99
122
  }
100
123
  if (request.method !== 'POST' || !['/v1/query', '/mcp'].includes(path))
@@ -113,28 +136,15 @@ export async function startMapServer(options) {
113
136
  if (!credential)
114
137
  fail('MAP_UNAUTHENTICATED');
115
138
  consume(credential.bucket, maxRequests, windowMs);
116
- if (count(request, 'content-type') !== 1 || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers['content-type'] ?? '') ||
117
- count(request, 'content-length') > 1 || count(request, 'transfer-encoding') > 1 || request.headers['content-encoding'] !== undefined)
118
- fail('MAP_REQUEST_INVALID');
119
- const length = request.headers['content-length'];
120
- if (length !== undefined && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length))))
121
- fail('MAP_REQUEST_INVALID');
122
- if (length !== undefined && Number(length) > requestLimit)
123
- fail('MAP_REQUEST_TOO_LARGE');
124
- const bytes = await readBody(request, requestLimit, abort.signal);
139
+ // The credential and rate checks have passed; a refusal above already answered with its status.
140
+ const parsed = await readJsonBody(request, response, requestLimit, abort.signal);
141
+ received = true;
125
142
  check();
126
- const parsed = parseRequest(bytes, requestLimit);
127
143
  let output;
128
144
  let status = 200;
129
145
  let mime = 'application/json';
130
146
  if (path === '/v1/query') {
131
- let envelope;
132
- try {
133
- envelope = fields(parsed, ['operation', 'args'], ['operation', 'args']);
134
- }
135
- catch {
136
- fail('MAP_REQUEST_INVALID');
137
- }
147
+ const envelope = queryEnvelope(parsed);
138
148
  const result = await service.query(credential.scope, envelope.operation, envelope.args);
139
149
  check();
140
150
  output = Buffer.from(JSON.stringify(result));
@@ -143,11 +153,15 @@ export async function startMapServer(options) {
143
153
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
144
154
  fail('MAP_REQUEST_INVALID');
145
155
  const method = parsed.method;
146
- if (typeof method !== 'string' || !['initialize', 'notifications/initialized', 'server/discover', 'tools/list', 'tools/call', 'ping'].includes(method))
156
+ if (typeof method !== 'string' ||
157
+ !['initialize', 'notifications/initialized', 'server/discover', 'tools/list', 'tools/call', 'ping'].includes(method))
147
158
  fail('MAP_REQUEST_DENIED');
148
159
  const handler = createMapMcpHandler(service, credential.scope);
149
160
  try {
150
- const forwarded = new Headers({ 'content-type': 'application/json', accept: 'application/json, text/event-stream' });
161
+ const forwarded = new Headers({
162
+ 'content-type': 'application/json',
163
+ accept: 'application/json, text/event-stream',
164
+ });
151
165
  for (const name of ['mcp-protocol-version', 'mcp-session-id']) {
152
166
  if (count(request, name) > 1)
153
167
  fail('MAP_REQUEST_INVALID');
@@ -155,8 +169,12 @@ export async function startMapServer(options) {
155
169
  if (typeof value === 'string')
156
170
  forwarded.set(name, value);
157
171
  }
158
- const result = await handler.fetch(new Request(`http://${host}/mcp`, { method: 'POST', headers: forwarded,
159
- body: JSON.stringify(parsed), signal: abort.signal }), { parsedBody: parsed });
172
+ const result = await handler.fetch(new Request(`http://${host}/mcp`, {
173
+ method: 'POST',
174
+ headers: forwarded,
175
+ body: JSON.stringify(parsed),
176
+ signal: abort.signal,
177
+ }), { parsedBody: parsed });
160
178
  const reader = result.body?.getReader();
161
179
  const chunks = [];
162
180
  let size = 0;
@@ -193,7 +211,7 @@ export async function startMapServer(options) {
193
211
  response.end(output);
194
212
  }
195
213
  catch (error) {
196
- errorResponse(response, safeError(error));
214
+ respondError(response, error, received);
197
215
  }
198
216
  finally {
199
217
  if (timer)
@@ -208,19 +226,27 @@ export async function startMapServer(options) {
208
226
  const timer = setTimeout(() => socket.destroy(), timeout);
209
227
  timer.unref();
210
228
  socketTimers.set(socket, timer);
211
- socket.once('close', () => { sockets.delete(socket); clearTimeout(socketTimers.get(socket)); });
229
+ socket.once('close', () => {
230
+ sockets.delete(socket);
231
+ clearTimeout(socketTimers.get(socket));
232
+ });
233
+ });
234
+ refuseOtherTraffic(server, handle);
235
+ await new Promise((resolve, reject) => {
236
+ server.once('error', reject);
237
+ server.listen(port, '127.0.0.1', () => {
238
+ server.off('error', reject);
239
+ resolve();
240
+ });
212
241
  });
213
- server.on('checkContinue', (request, response) => { void handle(request, response); });
214
- server.on('checkExpectation', (_request, response) => errorResponse(response, 'MAP_REQUEST_DENIED'));
215
- server.on('connect', (_request, socket) => socket.destroy());
216
- server.on('upgrade', (_request, socket) => socket.destroy());
217
- server.on('clientError', (_error, socket) => socket.destroy());
218
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
219
242
  const address = server.address();
220
243
  if (!address || typeof address === 'string')
221
244
  fail('MAP_UNAVAILABLE');
222
245
  host = `127.0.0.1:${address.port}`;
223
- return { server, url: `http://${host}`, async close() {
246
+ return {
247
+ server,
248
+ url: `http://${host}`,
249
+ async close() {
224
250
  if (closing)
225
251
  return;
226
252
  closing = true;
@@ -229,5 +255,6 @@ export async function startMapServer(options) {
229
255
  for (const socket of sockets)
230
256
  socket.destroy();
231
257
  await new Promise(resolve => server.close(() => resolve()));
232
- } };
258
+ },
259
+ };
233
260
  }