@amalgm/chat 0.2.0 → 0.2.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 CHANGED
@@ -92,3 +92,8 @@ npm install
92
92
  npm test
93
93
  npm pack --dry-run
94
94
  ```
95
+
96
+ Package releases use `.github/workflows/publish-package.yml`. A reviewed main
97
+ commit is tagged exactly `v<package version>`; trusted npm publishing stages
98
+ that package, and a maintainer approves the staged artifact with 2FA. Manual
99
+ dirty-worktree publishing is not a release path.
@@ -15,3 +15,8 @@ If issuance or proof creation fails, egress returns `503` without contacting
15
15
  the provider. The provider adapter propagates failure through the ordinary
16
16
  runtime pump, and Chat seals the turn as `failed`; stable host error codes are
17
17
  preserved in the durable turn error.
18
+
19
+ The local relay accepts only its session capability, removes caller-supplied
20
+ credentials and Amalgm metadata, bounds buffered request bytes, rejects
21
+ redirects, and applies response backpressure. These are part of the egress
22
+ boundary, not provider-specific adapter behavior.
@@ -1,9 +1,17 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { once } from 'node:events';
3
+
4
+ const MAX_EGRESS_REQUEST_BYTES = 64 * 1024 * 1024;
5
+
1
6
  const HOP_HEADERS = new Set([
2
7
  'accept-encoding',
3
8
  'connection',
4
9
  'content-encoding',
5
10
  'content-length',
11
+ 'cookie',
12
+ 'dpop',
6
13
  'host',
14
+ 'proxy-authorization',
7
15
  'transfer-encoding',
8
16
  ]);
9
17
 
@@ -25,7 +33,12 @@ function requestHeaders(request) {
25
33
  const headers = {};
26
34
  for (const [name, value] of Object.entries(request.headers || {})) {
27
35
  const lower = name.toLowerCase();
28
- if (HOP_HEADERS.has(lower) || lower === 'authorization' || lower === 'x-api-key') continue;
36
+ if (
37
+ HOP_HEADERS.has(lower)
38
+ || lower === 'authorization'
39
+ || lower === 'x-api-key'
40
+ || lower.startsWith('x-amalgm-')
41
+ ) continue;
29
42
  if (value !== undefined) headers[lower] = Array.isArray(value) ? value[0] : value;
30
43
  }
31
44
  return headers;
@@ -43,17 +56,18 @@ function responseHeaders(response) {
43
56
  export async function forwardPlatformEgress({ request, response, contract, proxy, upstreamPath }) {
44
57
  const authorization = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
45
58
  const apiKey = String(request.headers['x-api-key'] || '').trim();
46
- if (!contract.auth?.tokenRef || (authorization || apiKey) !== contract.auth.tokenRef) {
59
+ const presented = authorization || apiKey;
60
+ if (!contract.auth?.tokenRef || !secretMatches(presented, contract.auth.tokenRef)) {
47
61
  response.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
48
62
  response.end(JSON.stringify({ error: 'Invalid Chat egress token' }));
49
63
  return;
50
64
  }
51
65
  const method = request.method || 'GET';
52
66
  const url = `${proxy.baseUrl.replace(/\/$/, '')}${upstreamPath}`;
53
- const body = ['GET', 'HEAD'].includes(method) ? undefined : await requestBytes(request);
54
67
  const fetchImpl = proxy.fetch || fetch;
55
68
  let upstream;
56
69
  try {
70
+ const body = ['GET', 'HEAD'].includes(method) ? undefined : await requestBytes(request);
57
71
  for (let attempt = 0; attempt < 2; attempt += 1) {
58
72
  const grant = await proxy.credentialBroker.authorize({
59
73
  audience: 'amalgm-api-proxy',
@@ -70,6 +84,7 @@ export async function forwardPlatformEgress({ request, response, contract, proxy
70
84
  dpop: grant.dpop,
71
85
  },
72
86
  body,
87
+ redirect: 'error',
73
88
  });
74
89
  if (upstream.status !== 401 || attempt === 1) break;
75
90
  await upstream.body?.cancel().catch(() => undefined);
@@ -90,18 +105,19 @@ export async function forwardPlatformEgress({ request, response, contract, proxy
90
105
  while (true) {
91
106
  const { done, value } = await reader.read();
92
107
  if (done) break;
93
- if (value) response.write(Buffer.from(value));
108
+ if (value && response.write(Buffer.from(value)) === false) await once(response, 'drain');
94
109
  }
95
110
  }
96
111
  response.end();
97
112
  }
98
113
 
99
114
  function scopeForPath(path) {
100
- if (path.startsWith('/anthropic')) return 'llm:anthropic';
101
- if (path.startsWith('/ai_gateway')) return 'llm:gateway';
102
- if (path.startsWith('/google') || path.startsWith('/gemini')) return 'llm:google';
103
- if (path.startsWith('/xai')) return 'llm:xai';
104
- if (path.startsWith('/openai')) return 'llm:openai';
115
+ const provider = path.match(/^\/([^/?]+)(?:[/?]|$)/)?.[1];
116
+ if (provider === 'anthropic') return 'llm:anthropic';
117
+ if (provider === 'ai_gateway') return 'llm:gateway';
118
+ if (provider === 'google' || provider === 'gemini') return 'llm:google';
119
+ if (provider === 'xai') return 'llm:xai';
120
+ if (provider === 'openai') return 'llm:openai';
105
121
  throw Object.assign(new Error('Platform egress route has no authorization scope'), {
106
122
  code: 'EGRESS_SCOPE_UNKNOWN',
107
123
  });
@@ -109,6 +125,22 @@ function scopeForPath(path) {
109
125
 
110
126
  async function requestBytes(request) {
111
127
  const chunks = [];
112
- for await (const chunk of request) chunks.push(Buffer.from(chunk));
128
+ let bytes = 0;
129
+ for await (const chunk of request) {
130
+ const value = Buffer.from(chunk);
131
+ bytes += value.byteLength;
132
+ if (bytes > MAX_EGRESS_REQUEST_BYTES) {
133
+ throw Object.assign(new Error('Platform egress request exceeds its byte limit'), {
134
+ code: 'EGRESS_REQUEST_TOO_LARGE',
135
+ });
136
+ }
137
+ chunks.push(value);
138
+ }
113
139
  return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
114
140
  }
141
+
142
+ function secretMatches(presented, expected) {
143
+ const left = Buffer.from(presented);
144
+ const right = Buffer.from(expected);
145
+ return left.byteLength === right.byteLength && timingSafeEqual(left, right);
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/chat",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A provider-agnostic agent chat SDK with ACP content, prepared execution, normalized streams, and usage.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -81,8 +81,7 @@
81
81
  },
82
82
  "scripts": {
83
83
  "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
84
- "test": "npm run build && node --test tests/",
85
- "test:parity": "npm run build && node --test tests/parity/"
84
+ "test": "npm run build && node --test"
86
85
  },
87
86
  "dependencies": {
88
87
  "@agentclientprotocol/sdk": "^1.4.0",