gogcli-mcp 2.29.0 → 2.30.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.
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { uploadToBlobStore, RUNNER_UPLOAD_TIMEOUT_MS } from '../src/blob-upload.js';
3
+
4
+ // A minted PUT URL, with the two shapes the scrubber has to recognise: the
5
+ // whole string, and the bare signature it carries.
6
+ const SIG = 'AbCdEf-_1234';
7
+ const PUT_URL = `https://host.example/b/reg_1/gmail/m1/Guest%20Copy.pdf?exp=1757000000000&sig=${SIG}`;
8
+
9
+ const ENV = { GOG_RUNNER_URL: 'https://runner.example', GOG_RUNNER_KEY: 'runner-key' };
10
+
11
+ const REQUEST = { path: '/tmp/gog-attachments/m1/Guest_Copy.pdf', url: PUT_URL, contentType: 'application/pdf' };
12
+
13
+ function answers(status: number, body: unknown, ok = status >= 200 && status < 300) {
14
+ return vi.fn(async () => ({
15
+ ok,
16
+ status,
17
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
18
+ }));
19
+ }
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ vi.unstubAllEnvs();
24
+ });
25
+
26
+ describe('uploadToBlobStore', () => {
27
+ it('POSTs the path, the URL and the content type to the runner, bearing its key', async () => {
28
+ const fetchMock = answers(200, { ok: true, status: 200, bytes: 99723 });
29
+ vi.stubGlobal('fetch', fetchMock);
30
+
31
+ const outcome = await uploadToBlobStore(REQUEST, { env: { ...ENV, GOG_RUNNER_URL: 'https://runner.example//' } });
32
+
33
+ expect(outcome).toEqual({ bytes: 99723, status: 200 });
34
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
35
+ // Trailing slashes trimmed — `<endpoint>//upload` is a different route.
36
+ expect(url).toBe('https://runner.example/upload');
37
+ expect(init.method).toBe('POST');
38
+ expect(init.headers).toMatchObject({
39
+ Authorization: 'Bearer runner-key',
40
+ 'Content-Type': 'application/json',
41
+ });
42
+ expect(JSON.parse(init.body as string)).toEqual({
43
+ path: REQUEST.path,
44
+ url: PUT_URL,
45
+ contentType: 'application/pdf',
46
+ });
47
+ // A deadline of our own: the runner's 120 s is an INACTIVITY timer and
48
+ // cannot bound a transfer that keeps dribbling, so nothing else protects
49
+ // the MCP caller from a request that never ends.
50
+ expect(init.signal).toBeInstanceOf(AbortSignal);
51
+ });
52
+
53
+ it('refuses, without dialling, when no gog runner is configured', async () => {
54
+ const fetchMock = answers(200, { ok: true });
55
+ vi.stubGlobal('fetch', fetchMock);
56
+
57
+ await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_URL: ENV.GOG_RUNNER_URL } }))
58
+ .rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
59
+ await expect(uploadToBlobStore(REQUEST, { env: { GOG_RUNNER_KEY: ENV.GOG_RUNNER_KEY } }))
60
+ .rejects.toThrow(/GOG_RUNNER_URL.*GOG_RUNNER_KEY/s);
61
+ expect(fetchMock).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('reports the blob store\'s own verdict when the runner relays a refusal', async () => {
65
+ vi.stubGlobal('fetch', answers(422, {
66
+ error: 'the blob store refused the upload with 403: forbidden',
67
+ status: 403,
68
+ retryable: false,
69
+ }));
70
+
71
+ await expect(uploadToBlobStore(REQUEST, { env: ENV }))
72
+ .rejects.toThrow(/the blob store refused the upload with 403: forbidden/);
73
+ });
74
+
75
+ it('names the runner\'s status when it answers with no error text', async () => {
76
+ vi.stubGlobal('fetch', answers(502, { retryable: true }));
77
+
78
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/502/);
79
+ });
80
+
81
+ it('survives an answer that is not JSON at all (a proxy\'s HTML error page)', async () => {
82
+ vi.stubGlobal('fetch', answers(504, '<html>gateway timeout</html>'));
83
+
84
+ // The body is quoted, not parsed — and the throw is about the upload, never
85
+ // a SyntaxError from somewhere inside this module.
86
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/504/);
87
+ });
88
+
89
+ // A signed URL is a credential with up to 24 h of anybody-who-holds-it access
90
+ // to the object. The runner scrubs its own words, but the far side's text is
91
+ // a third party's and may quote the request URL straight back — and an error
92
+ // thrown here is the one value on this path that is expected to be logged.
93
+ it('never lets the signed URL, or its bare signature, into the error it throws', async () => {
94
+ vi.stubGlobal('fetch', answers(422, { error: `refused: PUT ${PUT_URL} (sig ${SIG})`, status: 403 }));
95
+
96
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
97
+ expect(err).toBeInstanceOf(Error);
98
+ expect((err as Error).message).not.toContain(PUT_URL);
99
+ expect((err as Error).message).not.toContain(SIG);
100
+ expect((err as Error).message).toContain('<signed url>');
101
+ });
102
+
103
+ it('scrubs a transport failure too — a rejected fetch may quote the URL', async () => {
104
+ vi.stubGlobal('fetch', vi.fn(async () => {
105
+ throw new Error(`connect ECONNREFUSED while sending ${PUT_URL}`);
106
+ }));
107
+
108
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
109
+ expect((err as Error).message).not.toContain(PUT_URL);
110
+ expect((err as Error).message).not.toContain(SIG);
111
+ expect((err as Error).message).toContain('ECONNREFUSED');
112
+ });
113
+
114
+ it('reports a transport failure that threw something other than an Error', async () => {
115
+ vi.stubGlobal('fetch', vi.fn(async () => { throw 'socket hang up'; }));
116
+
117
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/socket hang up/);
118
+ });
119
+
120
+ // The response ARRIVES and then its body fails to read — a severed or
121
+ // truncated answer. That is a third shape of transport failure, and the
122
+ // module's contract is about every one of them: nothing it throws carries the
123
+ // URL. undici's own body errors ('terminated', 'Premature close') happen to
124
+ // name nothing, so this is the guarantee being a property of the function
125
+ // rather than a fact about today's runtime.
126
+ it('scrubs a body that fails to read, as it scrubs a failed dial', async () => {
127
+ vi.stubGlobal('fetch', vi.fn(async () => ({
128
+ ok: true,
129
+ status: 200,
130
+ text: async () => { throw new Error(`terminated while reading ${PUT_URL}`); },
131
+ })));
132
+
133
+ const err = await uploadToBlobStore(REQUEST, { env: ENV }).catch((e: Error) => e);
134
+ expect(err).toBeInstanceOf(Error);
135
+ expect((err as Error).message).not.toContain(PUT_URL);
136
+ expect((err as Error).message).not.toContain(SIG);
137
+ expect((err as Error).message).toContain('terminated');
138
+ });
139
+
140
+ it('reports a body read that rejected with something other than an Error', async () => {
141
+ vi.stubGlobal('fetch', vi.fn(async () => ({
142
+ ok: true,
143
+ status: 200,
144
+ text: async () => { throw 'premature close'; },
145
+ })));
146
+
147
+ await expect(uploadToBlobStore(REQUEST, { env: ENV })).rejects.toThrow(/premature close/);
148
+ });
149
+
150
+ // The whole-string replacement is what does the work for a URL with no `sig`
151
+ // at all — and a URL that is not parseable must still be redacted rather than
152
+ // throwing on the way to a redaction.
153
+ it('scrubs a URL that carries no signature and does not parse', async () => {
154
+ const odd = 'not a url at all';
155
+ vi.stubGlobal('fetch', answers(422, { error: `refused: ${odd}`, status: 400 }));
156
+
157
+ const err = await uploadToBlobStore({ ...REQUEST, url: odd }, { env: ENV }).catch((e: Error) => e);
158
+ expect((err as Error).message).not.toContain(odd);
159
+ expect((err as Error).message).toContain('<signed url>');
160
+ });
161
+
162
+ // `split('')` cuts a message into single characters and interleaves the
163
+ // replacement between every one of them, so an empty needle does not redact a
164
+ // message, it destroys it. Today's one call site always mints a URL first —
165
+ // this is a property of the function, which is why it is asserted on it.
166
+ it('an empty URL leaves the message intact rather than shredding it', async () => {
167
+ vi.stubGlobal('fetch', answers(422, { error: 'the blob store refused the upload', status: 403 }));
168
+
169
+ const err = await uploadToBlobStore({ ...REQUEST, url: '' }, { env: ENV }).catch((e: Error) => e);
170
+ expect((err as Error).message).toContain('the blob store refused the upload');
171
+ expect((err as Error).message).not.toContain('<signed url>');
172
+ });
173
+
174
+ it('reads the ambient environment, and its own deadline, when given neither', async () => {
175
+ const fetchMock = answers(200, { ok: true, status: 200, bytes: 12 });
176
+ vi.stubGlobal('fetch', fetchMock);
177
+ vi.stubEnv('GOG_RUNNER_URL', ENV.GOG_RUNNER_URL);
178
+ vi.stubEnv('GOG_RUNNER_KEY', ENV.GOG_RUNNER_KEY);
179
+
180
+ await expect(uploadToBlobStore(REQUEST)).resolves.toEqual({ bytes: 12, status: 200 });
181
+ expect((fetchMock.mock.calls[0] as [string, RequestInit])[0]).toBe('https://runner.example/upload');
182
+ });
183
+
184
+ it('exposes its deadline as a constant rather than a literal at the call site', () => {
185
+ expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(30_000);
186
+ });
187
+
188
+ // The rule this repo already applies at the other hop (`DEADLINE_GRACE_MS` in
189
+ // connector-runtime.ts: 30 s backend budget + 5 s): the CALLER's deadline sits
190
+ // ABOVE the backend's own, so the backend loses the race only when it
191
+ // genuinely cannot answer. The doc block here cited that rule while the
192
+ // numbers inverted it — 90 s against the runner's 120 s — so a socket that
193
+ // went quiet was aborted on this side 30 s before the runner's own timer could
194
+ // name it, turning "the upload timed out after 120000ms" into exactly the
195
+ // opaque client abort the rule exists to prevent.
196
+ //
197
+ // Restated rather than imported: `fly-gog-runner/server.mjs` must not be
198
+ // pulled into the Worker bundle (the same reason the attachment ceilings are
199
+ // restated in attachments.ts), so the two move by hand and this is the guard.
200
+ it('sits above the runner\'s own upload timeout, so the runner answers first', () => {
201
+ const UPLOAD_TIMEOUT_MS_ON_THE_BOX = 120_000; // server.mjs UPLOAD_TIMEOUT_MS
202
+ expect(RUNNER_UPLOAD_TIMEOUT_MS).toBeGreaterThan(UPLOAD_TIMEOUT_MS_ON_THE_BOX);
203
+ });
204
+ });
@@ -0,0 +1,319 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { createHmac } from 'node:crypto';
3
+ import {
4
+ blobStoreFromEnv,
5
+ createBlobUrlMinter,
6
+ readPayload,
7
+ writePayload,
8
+ BLOB_URL_MAX_TTL_MS,
9
+ BLOB_URL_CEILING_MARGIN_MS,
10
+ BLOB_URL_DEFAULT_TTL_MS,
11
+ } from '../src/blob-urls.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Fixtures. These are VECTORS: the signatures below were computed against the
15
+ // payload shapes as mcp-host's `blob-key.ts` spells them, independently of this
16
+ // module's code. If a future edit changes how a payload is assembled — a
17
+ // separator, the order of the fields, the encoding of the MAC — these stop
18
+ // matching. That is the point.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const BASE_URL = 'https://mcp.example.com/b/reg_0123456789abcdef01234567';
22
+ const SIGNING_KEY = 'test-blob-signing-key';
23
+ const REGISTRATION_ID = 'reg_0123456789abcdef01234567';
24
+ const REST = 'gmail/attachments/report.pdf';
25
+ const OBJECT_KEY = `${REGISTRATION_ID}/${REST}`;
26
+ const EXP = 1767225600000;
27
+ const NOW = EXP - BLOB_URL_DEFAULT_TTL_MS;
28
+
29
+ /** base64url(HMAC-SHA256(SIGNING_KEY, `<key>\n<exp>`)). */
30
+ const READ_SIG = 'KrMn62kcDi-i9wW3JLvbZJdas4gP-EQuYoO-XMhcpNQ';
31
+ /** base64url(HMAC-SHA256(SIGNING_KEY, `put\0<key>\0application/pdf\0<exp>`)). */
32
+ const WRITE_PDF_SIG = 'qHPIRvchnjsbE8lam5dft3pIDBZu6Ae0HWZIG7kC9DQ';
33
+ /** The same key and exp, signed for `text/plain` instead. */
34
+ const WRITE_TEXT_SIG = 'FHtUlUVeEuREdbdKENy8rDU2ZETBQKF6V9IfaIWwWWo';
35
+
36
+ function sign(payload: string): string {
37
+ return createHmac('sha256', SIGNING_KEY)
38
+ .update(payload, 'utf8')
39
+ .digest('base64url');
40
+ }
41
+
42
+ const minter = () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
43
+
44
+ function params(url: string): URLSearchParams {
45
+ return new URL(url).searchParams;
46
+ }
47
+
48
+ afterEach(() => {
49
+ vi.unstubAllEnvs();
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe('payload shapes', () => {
55
+ it('signs a read as `<key>\\n<exp>` — a newline, never a NUL', () => {
56
+ const payload = readPayload(OBJECT_KEY, EXP);
57
+ expect(payload).toBe('reg_0123456789abcdef01234567/gmail/attachments/report.pdf\n1767225600000');
58
+ expect(payload).toContain('\n');
59
+ expect(payload).not.toContain('\0');
60
+ });
61
+
62
+ it('signs a write as `put\\0<key>\\0<ct>\\0<exp>` — NUL-separated, content type inside', () => {
63
+ const payload = writePayload(OBJECT_KEY, 'application/pdf', EXP);
64
+ // The NUL is written as a `\u0000` escape, never `\0`: `\0` followed by a
65
+ // DIGIT is a legacy octal escape, so `'\01767…'` is U+0001 + "767…" — the
66
+ // vector then silently pins the wrong bytes (it did, on this test's first run).
67
+ expect(payload).toBe(
68
+ 'put\u0000reg_0123456789abcdef01234567/gmail/attachments/report.pdf\u0000application/pdf\u00001767225600000',
69
+ );
70
+ expect(payload.split('\0')).toEqual([
71
+ 'put',
72
+ OBJECT_KEY,
73
+ 'application/pdf',
74
+ String(EXP),
75
+ ]);
76
+ expect(payload).not.toContain('\n');
77
+ });
78
+
79
+ it('never lets the two shapes converge', () => {
80
+ expect(readPayload(OBJECT_KEY, EXP)).not.toBe(writePayload(OBJECT_KEY, 'application/pdf', EXP));
81
+ // A read signature must be unreplayable as a write, whatever the content type.
82
+ expect(sign(readPayload(OBJECT_KEY, EXP))).not.toBe(
83
+ sign(writePayload(OBJECT_KEY, '', EXP)),
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('createBlobUrlMinter', () => {
89
+ it('takes the registration id from the base URL\'s last path segment', () => {
90
+ expect(minter().registrationId).toBe(REGISTRATION_ID);
91
+ });
92
+
93
+ it('mints a GET URL matching the pinned read vector', () => {
94
+ const url = minter().getUrl(REST, { now: NOW });
95
+ expect(url).toBe(
96
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
97
+ );
98
+ expect(params(url).get('sig')).toBe(sign(readPayload(OBJECT_KEY, EXP)));
99
+ });
100
+
101
+ it('mints a PUT URL matching the pinned write vector', () => {
102
+ const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
103
+ expect(target.url).toBe(
104
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${WRITE_PDF_SIG}`,
105
+ );
106
+ expect(params(target.url).get('sig')).toBe(
107
+ sign(writePayload(OBJECT_KEY, 'application/pdf', EXP)),
108
+ );
109
+ });
110
+
111
+ it('carries the content type it signed BACK with the URL', () => {
112
+ // The one field the gateway checks that no test in this repo can: it
113
+ // rebuilds the payload from the PUT's own `content-type` header
114
+ // (`gateway/src/blob.ts`, defaulting to application/octet-stream when the
115
+ // header is absent), so a caller that drops the header, lets an HTTP
116
+ // library default it, or re-cases it gets a 404 signature mismatch. Handing
117
+ // back a URL alone makes that a thing the uploader has to REMEMBER; handing
118
+ // back the pair makes it a thing it cannot drop.
119
+ const target = minter().putUrl(REST, 'application/pdf', { now: NOW });
120
+ expect(target.contentType).toBe('application/pdf');
121
+ expect(Object.keys(target).sort()).toEqual(['contentType', 'url']);
122
+ });
123
+
124
+ it('changes the signature when the content type changes', () => {
125
+ const pdf = params(minter().putUrl(REST, 'application/pdf', { now: NOW }).url).get('sig');
126
+ const text = params(minter().putUrl(REST, 'text/plain', { now: NOW }).url).get('sig');
127
+ expect(pdf).toBe(WRITE_PDF_SIG);
128
+ expect(text).toBe(WRITE_TEXT_SIG);
129
+ expect(pdf).not.toBe(text);
130
+ });
131
+
132
+ it('builds the object key as `<registrationId>/<rest>`, slashes and all', () => {
133
+ const url = minter().getUrl('a/b/c.pdf', { now: NOW });
134
+ // Raw, not `new URL(url).pathname` — same reason as the encoding cases below.
135
+ expect(url.split('?')[0]).toBe(`${BASE_URL}/a/b/c.pdf`);
136
+ expect(params(url).get('sig')).toBe(
137
+ sign(readPayload(`${REGISTRATION_ID}/a/b/c.pdf`, EXP)),
138
+ );
139
+ });
140
+
141
+ it('percent-encodes each URL segment while signing the logical key', () => {
142
+ const url = minter().getUrl('in box/a b.pdf', { now: NOW });
143
+ // Asserted on the RAW string the module returned, never through
144
+ // `new URL(url).pathname`: the parser percent-encodes a space itself on the
145
+ // way in, so a parsed pathname reads identically whether this module
146
+ // encoded anything or not — deleting `encodeURIComponent` from the wire
147
+ // left that assertion green (the one mutation of 25 that survived review).
148
+ expect(url).toBe(
149
+ `${BASE_URL}/in%20box/a%20b.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP))}`,
150
+ );
151
+ // The gateway decodes per segment before it verifies, so the SIGNED key is
152
+ // the logical one — an encoded key here would 404 on every space.
153
+ expect(params(url).get('sig')).toBe(
154
+ sign(readPayload(`${REGISTRATION_ID}/in box/a b.pdf`, EXP)),
155
+ );
156
+ });
157
+
158
+ it('percent-encodes a `#` on the wire, so the query is still a query', () => {
159
+ // `Invoice #1234.pdf` is an ordinary Gmail attachment name, and it is the
160
+ // case a URL parser will NOT repair for us: unencoded, everything from the
161
+ // `#` is a FRAGMENT, so `?exp=&sig=` never reaches the gateway and the
162
+ // answer is 403 "missing signature" with nothing to debug. `?` truncates
163
+ // the path the same way. Raw string again, for the reason above.
164
+ const url = minter().getUrl('Invoice #1234.pdf', { now: NOW });
165
+ expect(url).toBe(
166
+ `${BASE_URL}/Invoice%20%231234.pdf?exp=${EXP}&sig=${sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP))}`,
167
+ );
168
+ expect(url).not.toContain('#');
169
+ expect(params(url).get('sig')).toBe(
170
+ sign(readPayload(`${REGISTRATION_ID}/Invoice #1234.pdf`, EXP)),
171
+ );
172
+ });
173
+
174
+ it('tolerates a trailing slash on the base URL', () => {
175
+ const m = createBlobUrlMinter({ baseUrl: `${BASE_URL}/`, signingKey: SIGNING_KEY });
176
+ expect(m.registrationId).toBe(REGISTRATION_ID);
177
+ expect(m.getUrl(REST, { now: NOW })).toBe(
178
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
179
+ );
180
+ });
181
+
182
+ it('defaults the expiry to one hour', () => {
183
+ const exp = Number(params(minter().getUrl(REST, { now: NOW })).get('exp'));
184
+ expect(exp - NOW).toBe(BLOB_URL_DEFAULT_TTL_MS);
185
+ expect(BLOB_URL_DEFAULT_TTL_MS).toBe(60 * 60 * 1000);
186
+ });
187
+
188
+ it('honours a shorter ttl', () => {
189
+ const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 5000 })).get('exp'));
190
+ expect(exp).toBe(NOW + 5000);
191
+ });
192
+
193
+ it('clamps the expiry UNDER the gateway\'s 24-hour ceiling rather than trusting the caller', () => {
194
+ const exp = Number(
195
+ params(minter().getUrl(REST, { now: NOW, ttlMs: 7 * 24 * 60 * 60 * 1000 })).get('exp'),
196
+ );
197
+ expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
198
+ expect(BLOB_URL_MAX_TTL_MS).toBe(24 * 60 * 60 * 1000);
199
+ });
200
+
201
+ it('never mints an exp AT the ceiling, so a clock a hair ahead is not a 403', () => {
202
+ // `blob.ts` refuses with a strict `exp > Date.now() + MAX_TTL_MS`, judged on
203
+ // the GATEWAY's clock against an exp computed on the CHILD's. Landing
204
+ // exactly on the ceiling leaves zero skew budget: one millisecond of the
205
+ // child running ahead is a hard 403 "expiry out of range", whose message
206
+ // names no cause the caller can act on. And BLOB_URL_MAX_TTL_MS is exported,
207
+ // so `ttlMs: BLOB_URL_MAX_TTL_MS` is the natural way to ask for the longest
208
+ // legal link — the API invites exactly the request that would break.
209
+ for (const ttlMs of [BLOB_URL_MAX_TTL_MS, BLOB_URL_MAX_TTL_MS + 1, Number.MAX_SAFE_INTEGER]) {
210
+ const exp = Number(params(minter().getUrl(REST, { now: NOW, ttlMs })).get('exp'));
211
+ expect(exp).toBeLessThan(NOW + BLOB_URL_MAX_TTL_MS);
212
+ expect(exp).toBe(NOW + BLOB_URL_MAX_TTL_MS - BLOB_URL_CEILING_MARGIN_MS);
213
+ }
214
+ expect(BLOB_URL_CEILING_MARGIN_MS).toBeGreaterThan(0);
215
+ });
216
+
217
+ it('clamps a zero or negative ttl up to one second, never to an already-dead link', () => {
218
+ expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: 0 })).get('exp'))).toBe(NOW + 1000);
219
+ expect(Number(params(minter().getUrl(REST, { now: NOW, ttlMs: -1 })).get('exp'))).toBe(NOW + 1000);
220
+ });
221
+
222
+ it('reads the clock when no `now` is given', () => {
223
+ vi.useFakeTimers();
224
+ try {
225
+ vi.setSystemTime(NOW);
226
+ expect(minter().getUrl(REST)).toBe(
227
+ `${BASE_URL}/gmail/attachments/report.pdf?exp=${EXP}&sig=${READ_SIG}`,
228
+ );
229
+ } finally {
230
+ vi.useRealTimers();
231
+ }
232
+ });
233
+
234
+ it.each([
235
+ 'https://mcp.example.com/b/',
236
+ 'https://mcp.example.com/b',
237
+ 'https://mcp.example.com/',
238
+ 'https://mcp.example.com',
239
+ ])('refuses the base URL %j — no registration id in the path', (baseUrl) => {
240
+ expect(() => createBlobUrlMinter({ baseUrl, signingKey: 'k' }))
241
+ .toThrow(/registration id/i);
242
+ });
243
+
244
+ it('refuses a base URL that is not a URL', () => {
245
+ expect(() => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: 'k' }))
246
+ .toThrow(/MCP_BLOB_BASE_URL/);
247
+ });
248
+
249
+ it('refuses an empty signing key', () => {
250
+ expect(() => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' }))
251
+ .toThrow(/MCP_BLOB_SIGNING_KEY/);
252
+ });
253
+
254
+ it.each([
255
+ ['', 'empty'],
256
+ ['/', 'empty segment'],
257
+ ['a//b', 'empty segment'],
258
+ ['../etc/passwd', 'traversal'],
259
+ ['a/./b', 'traversal'],
260
+ ['a/../b', 'traversal'],
261
+ ])('refuses the object path %j (%s)', (rest) => {
262
+ // The gateway refuses these outright rather than normalising them, so a URL
263
+ // minted for one would 404 at the door. Fail here, where the message can say why.
264
+ expect(() => minter().getUrl(rest, { now: NOW })).toThrow(/object path/i);
265
+ });
266
+
267
+ // EVERY refusal this module makes, not only the one easiest to reach. The key
268
+ // is a credential and so is a signed URL, while an error message is the one
269
+ // value here that is EXPECTED to be logged — so the guard has to bind the
270
+ // constructor's three refusals as well as the mint-time one. With only the
271
+ // `../escape` case it did not: rewriting the unparseable-base-URL throw to
272
+ // interpolate `config.signingKey` left the whole suite green.
273
+ it.each([
274
+ ['empty signing key', () => createBlobUrlMinter({ baseUrl: BASE_URL, signingKey: '' })],
275
+ ['unparseable base URL', () => createBlobUrlMinter({ baseUrl: 'not a url', signingKey: SIGNING_KEY })],
276
+ ['base URL with no registration id', () => createBlobUrlMinter({ baseUrl: 'https://mcp.example.com/b/', signingKey: SIGNING_KEY })],
277
+ ['refused object path', () => minter().getUrl('../escape', { now: NOW })],
278
+ ])('never puts the signing key or a signed URL in the error for a %s', (_label, refuse) => {
279
+ try {
280
+ refuse();
281
+ expect.unreachable('should have thrown');
282
+ } catch (err) {
283
+ expect(String(err)).not.toContain(SIGNING_KEY);
284
+ expect(String(err)).not.toContain('sig=');
285
+ }
286
+ });
287
+ });
288
+
289
+ describe('blobStoreFromEnv', () => {
290
+ it('returns the pair when both variables are set', () => {
291
+ vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
292
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
293
+ expect(blobStoreFromEnv()).toEqual({ baseUrl: BASE_URL, signingKey: SIGNING_KEY });
294
+ });
295
+
296
+ it('returns undefined on a local stdio install, where neither is set', () => {
297
+ vi.stubEnv('MCP_BLOB_BASE_URL', '');
298
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
299
+ expect(blobStoreFromEnv()).toBeUndefined();
300
+ });
301
+
302
+ it('returns undefined when only the base URL is set', () => {
303
+ vi.stubEnv('MCP_BLOB_BASE_URL', BASE_URL);
304
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', '');
305
+ expect(blobStoreFromEnv()).toBeUndefined();
306
+ });
307
+
308
+ it('returns undefined when only the signing key is set', () => {
309
+ vi.stubEnv('MCP_BLOB_BASE_URL', '');
310
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
311
+ expect(blobStoreFromEnv()).toBeUndefined();
312
+ });
313
+
314
+ it('treats an unresolved .mcpb placeholder as unset', () => {
315
+ vi.stubEnv('MCP_BLOB_BASE_URL', '${user_config.blob_base_url}');
316
+ vi.stubEnv('MCP_BLOB_SIGNING_KEY', SIGNING_KEY);
317
+ expect(blobStoreFromEnv()).toBeUndefined();
318
+ });
319
+ });
@@ -499,7 +499,7 @@ describe('run', () => {
499
499
  }
500
500
  });
501
501
 
502
- it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*_API_KEY/*_PRIVATE_KEY vars', async () => {
502
+ it('strips GOOGLE_APPLICATION_CREDENTIALS and *_TOKEN/*_SECRET/*_KEY/*_CREDENTIALS vars', async () => {
503
503
  const spawner = makeSpawner(0, '{}');
504
504
  const snapshot = {
505
505
  GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
@@ -532,6 +532,67 @@ describe('run', () => {
532
532
  }
533
533
  });
534
534
 
535
+ // The suffix list was `_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY` — four spellings
536
+ // of "a key", none of which is a bare `_KEY`. Two credentials this repo hands
537
+ // its own process sit in exactly that gap: `MCP_BLOB_SIGNING_KEY` (mints the
538
+ // signed blob URLs a `deliver="url"` download is uploaded to) and
539
+ // `GOG_RUNNER_KEY` (the bearer for the Fly backend, and for `POST /upload`,
540
+ // which is arbitrary-gog-argv on that box). Neither is anything `gog` reads.
541
+ it('strips a bare *_KEY var — the host signing key and the runner bearer', async () => {
542
+ const spawner = makeSpawner(0, '{}');
543
+ const snapshot = {
544
+ MCP_BLOB_SIGNING_KEY: process.env.MCP_BLOB_SIGNING_KEY,
545
+ GOG_RUNNER_KEY: process.env.GOG_RUNNER_KEY,
546
+ STRIPE_KEY: process.env.STRIPE_KEY,
547
+ AWS_CREDENTIALS: process.env.AWS_CREDENTIALS,
548
+ };
549
+ process.env.MCP_BLOB_SIGNING_KEY = 'blob-signing-secret';
550
+ process.env.GOG_RUNNER_KEY = 'runner-bearer-secret';
551
+ process.env.STRIPE_KEY = 'sk-live-secret';
552
+ process.env.AWS_CREDENTIALS = '/path/to/creds';
553
+ try {
554
+ await run(['docs', 'cat', 'id'], { spawner });
555
+ const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
556
+ expect(envPassed.MCP_BLOB_SIGNING_KEY).toBeUndefined();
557
+ expect(envPassed.GOG_RUNNER_KEY).toBeUndefined();
558
+ expect(envPassed.STRIPE_KEY).toBeUndefined();
559
+ // `_CREDENTIALS`, generalising the one named GOOGLE_APPLICATION_CREDENTIALS.
560
+ expect(envPassed.AWS_CREDENTIALS).toBeUndefined();
561
+ } finally {
562
+ for (const [k, v] of Object.entries(snapshot)) {
563
+ if (v === undefined) delete process.env[k];
564
+ else process.env[k] = v;
565
+ }
566
+ }
567
+ });
568
+
569
+ // The CONTROL on widening, and the reason the list is not simply "anything
570
+ // that smells like a credential": `GOG_KEYRING_PASSWORD` is how gog decrypts
571
+ // its own file keyring (`GOG_KEYRING_BACKEND=file`), so a `_PASSWORD` rule
572
+ // would strip the one credential the child legitimately needs and turn every
573
+ // call into an auth failure. `GOG_KEYRING_BACKEND` is the same story without
574
+ // the secret.
575
+ it('keeps the credentials gog itself reads from the environment', async () => {
576
+ const spawner = makeSpawner(0, '{}');
577
+ const snapshot = {
578
+ GOG_KEYRING_PASSWORD: process.env.GOG_KEYRING_PASSWORD,
579
+ GOG_KEYRING_BACKEND: process.env.GOG_KEYRING_BACKEND,
580
+ };
581
+ process.env.GOG_KEYRING_PASSWORD = 'keyring-pass';
582
+ process.env.GOG_KEYRING_BACKEND = 'file';
583
+ try {
584
+ await run(['docs', 'cat', 'id'], { spawner });
585
+ const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
586
+ expect(envPassed.GOG_KEYRING_PASSWORD).toBe('keyring-pass');
587
+ expect(envPassed.GOG_KEYRING_BACKEND).toBe('file');
588
+ } finally {
589
+ for (const [k, v] of Object.entries(snapshot)) {
590
+ if (v === undefined) delete process.env[k];
591
+ else process.env[k] = v;
592
+ }
593
+ }
594
+ });
595
+
535
596
  it('redacts Bearer with quoted/encoded characters', async () => {
536
597
  const stderrLeak = 'http 401: header was Bearer eyJ.test+slash/equal=padding more text';
537
598
  const spawner = makeSpawner(1, '', stderrLeak);
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createRequire } from 'node:module';
3
+ import { realpathSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // The zod counterpart of sdk-single-copy.test.ts, guarding the invariant that
7
+ // broke dependabot #333: the whole monorepo must resolve ONE copy of zod.
8
+ //
9
+ // Same shape of failure, a different package. `@cloudflare/vitest-pool-workers`
10
+ // (a root devDependency) declares zod as an **exact** pin, so it takes the
11
+ // hoisted root slot that `@chrischall/mcp-utils` and the MCP SDK resolve their
12
+ // zod peer from. The moment our workspaces ask for a newer zod than that pin,
13
+ // each nests its own copy — and because a `ZodType` carries brand-bearing
14
+ // internals, TypeScript compares the two NOMINALLY: every schema our tools hand
15
+ // `registerTool` fails with `TS2322: Type 'ZodString' is not assignable to type
16
+ // 'AnySchema'`, with no API change and nothing to fix in the source. #333 split
17
+ // the tree exactly that way and produced 11,024 type errors from a bump of one
18
+ // patch-level dependency.
19
+ //
20
+ // Read the resolved paths in such an error, not the signature.
21
+ //
22
+ // This asserts resolution identity rather than a version string: the failure is
23
+ // "two copies", not "the wrong version", and pinning a version here would just
24
+ // have to be edited on every future bump.
25
+ describe('zod is installed exactly once', () => {
26
+ const here = createRequire(import.meta.url);
27
+
28
+ // `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
29
+ // entry: these packages are ESM-only, so their `exports` maps carry no
30
+ // `require` condition and CJS resolution of the bare specifier throws.
31
+ const resolveFrom = (specifier: string): string =>
32
+ realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve('zod'));
33
+
34
+ it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
35
+ // mcp-utils declares zod as a peer, and its `accountParam` / `viewParam` /
36
+ // `paginationParams` helpers build the very schemas our registrars pass to
37
+ // `registerTool`, so its copy is the one they must be typed against.
38
+ expect(resolveFrom('@chrischall/mcp-utils')).toBe(realpathSync(here.resolve('zod')));
39
+ });
40
+
41
+ it('resolves to the same file for the MCP SDK, which types every tool schema', () => {
42
+ // `registerTool` accepts the raw shape and infers the handler's argument
43
+ // types from it; a second copy makes every one of those schemas foreign.
44
+ expect(resolveFrom('@modelcontextprotocol/sdk/server/mcp.js')).toBe(
45
+ realpathSync(here.resolve('zod')),
46
+ );
47
+ });
48
+
49
+ it('resolves to the same file for @cloudflare/vitest-pool-workers, which exact-pins zod', () => {
50
+ // The exact pin here is what captured the root hoist slot in #333, and it
51
+ // is why the root `overrides` block carries a zod entry.
52
+ expect(resolveFrom('@cloudflare/vitest-pool-workers')).toBe(
53
+ realpathSync(here.resolve('zod')),
54
+ );
55
+ });
56
+ });