@1agh/maude 0.53.0 → 0.53.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.
@@ -1263,7 +1263,13 @@ export function createHttp(
1263
1263
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1264
1264
  const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
1265
1265
  try {
1266
- const upstream = await fetch(endpoint, {
1266
+ // `globalThis.fetch` — NOT bare `fetch`: this module declares its own
1267
+ // `async function fetch(req: Request)` (the server fall-through handler)
1268
+ // in the same scope, which shadows the global. A bare call re-entered the
1269
+ // fall-through with a URL string, so `new URL(req.url)` threw
1270
+ // `"undefined" cannot be parsed as a URL` and every report died with a
1271
+ // plain-text HTTP 500 without ever leaving the machine.
1272
+ const upstream = await globalThis.fetch(endpoint, {
1267
1273
  method: 'POST',
1268
1274
  body: await req.formData(),
1269
1275
  signal: AbortSignal.timeout(30_000),
@@ -3948,7 +3954,11 @@ export function createHttp(
3948
3954
  '/index.html': () => serveFile(join(CLIENT_DIR, 'index.html')),
3949
3955
  } satisfies Record<string, (req: Request) => Response | Promise<Response>>;
3950
3956
 
3951
- async function fetch(req: Request): Promise<Response> {
3957
+ // Named `handleFallthrough`, not `fetch` — a same-named local function shadows
3958
+ // the global `fetch` for every call site in this module's scope, which is how
3959
+ // the `/_api/report` route ended up calling itself instead of the network (see
3960
+ // the `globalThis.fetch` comment on that route; RCA issue-report-a-bug-http-500).
3961
+ async function handleFallthrough(req: Request): Promise<Response> {
3952
3962
  try {
3953
3963
  const url = new URL(req.url);
3954
3964
  const pathname = url.pathname;
@@ -4342,7 +4352,7 @@ export function createHttp(
4342
4352
  ) as typeof routes;
4343
4353
 
4344
4354
  async function guardedFetch(req: Request): Promise<Response> {
4345
- return readOnlyRefusal(req) ?? fetch(req);
4355
+ return readOnlyRefusal(req) ?? handleFallthrough(req);
4346
4356
  }
4347
4357
 
4348
4358
  return { routes: guardedRoutes, fetch: guardedFetch, serveCanvasShell, isCanvasSafeRoute };
@@ -0,0 +1,125 @@
1
+ // Regression test for issue-report-a-bug-http-500 (RCA:
2
+ // .ai/logs/rca/issue-report-a-bug-http-500.md). `/_api/report` proxies the
3
+ // dialog's multipart body to `MAUDE_REPORT_URL`. A bare `fetch(endpoint, …)`
4
+ // call inside http.ts silently resolved to this module's OWN same-named
5
+ // fall-through handler instead of the global `fetch` — the request never left
6
+ // the machine, and the route mirrored a bogus local 500 back to the client.
7
+ // These tests boot a real server pointed at a real stub upstream, so a
8
+ // reintroduced shadow (or any other break in the proxy hop) fails loudly
9
+ // instead of only being caught by a mocked-upstream unit test.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { readFileSync } from 'node:fs';
13
+
14
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
15
+
16
+ async function bootStubUpstream(
17
+ handler: (req: Request) => Response | Promise<Response>
18
+ ): Promise<{ url: string; stop: () => void; received: () => number }> {
19
+ let received = 0;
20
+ const server = Bun.serve({
21
+ port: 0,
22
+ fetch: async (req) => {
23
+ received += 1;
24
+ return handler(req);
25
+ },
26
+ });
27
+ return {
28
+ url: `http://127.0.0.1:${server.port}`,
29
+ stop: () => server.stop(true),
30
+ received: () => received,
31
+ };
32
+ }
33
+
34
+ describe('/_api/report proxy hop', () => {
35
+ test('forwards the multipart body verbatim and mirrors a successful upstream response', async () => {
36
+ const { root } = makeSandbox();
37
+ let capturedBytes = 0;
38
+ let capturedHasReportPart = false;
39
+ const upstream = await bootStubUpstream(async (req) => {
40
+ const form = await req.formData();
41
+ capturedHasReportPart = typeof form.get('report') === 'string';
42
+ // Multipart parsing consumed the body — approximate "did real bytes
43
+ // arrive" by re-serializing what FormData kept.
44
+ capturedBytes = [...form.values()].reduce(
45
+ (n, v) => n + (typeof v === 'string' ? v.length : (v as Blob).size),
46
+ 0
47
+ );
48
+ return Response.json({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
49
+ });
50
+
51
+ const port = nextPort();
52
+ const proc = await bootServer(root, port, { MAUDE_REPORT_URL: `${upstream.url}/report` });
53
+ try {
54
+ const form = new FormData();
55
+ form.set(
56
+ 'report',
57
+ JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
58
+ );
59
+ form.append('screenshot', new Blob([new Uint8Array([1, 2, 3, 4])]), 'shot.png');
60
+
61
+ const res = await fetch(`http://localhost:${port}/_api/report`, {
62
+ method: 'POST',
63
+ body: form,
64
+ });
65
+ expect(res.status).toBe(200);
66
+ const json = await res.json();
67
+ expect(json).toEqual({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
68
+
69
+ expect(upstream.received()).toBe(1);
70
+ expect(capturedHasReportPart).toBe(true);
71
+ expect(capturedBytes).toBeGreaterThan(0);
72
+ } finally {
73
+ await killProc(proc);
74
+ upstream.stop();
75
+ }
76
+ });
77
+
78
+ test('an unreachable upstream yields 502 with a JSON error, not a bare 500', async () => {
79
+ const { root } = makeSandbox();
80
+ const port = nextPort();
81
+ // Nothing listens on this port — the fetch itself must fail/timeout.
82
+ const proc = await bootServer(root, port, { MAUDE_REPORT_URL: 'http://127.0.0.1:1/report' });
83
+ try {
84
+ const form = new FormData();
85
+ form.set(
86
+ 'report',
87
+ JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
88
+ );
89
+ const res = await fetch(`http://localhost:${port}/_api/report`, {
90
+ method: 'POST',
91
+ body: form,
92
+ });
93
+ expect(res.status).toBe(502);
94
+ const json = await res.json();
95
+ expect(json).toEqual({ error: 'report service unreachable' });
96
+ } finally {
97
+ await killProc(proc);
98
+ }
99
+ });
100
+ });
101
+
102
+ describe('no bare `fetch(` call sites in http.ts', () => {
103
+ test('every fetch call is either the global fetch (qualified) or a local handler name', () => {
104
+ const src = readFileSync(new URL('../http.ts', import.meta.url), 'utf8');
105
+ // A bare `fetch(` immediately preceded by a word-boundary and NOT preceded
106
+ // by a `.` (member access) or `function ` (declaration) would silently
107
+ // resolve to this file's own same-named fall-through handler instead of
108
+ // the global — exactly the bug this test guards against. `handleFallthrough`
109
+ // is the current, unambiguous name; this assertion also fails if the
110
+ // handler is ever renamed back to something call-site-ambiguous.
111
+ const bareFetchCalls = [...src.matchAll(/(?<![.\w])fetch\(/g)];
112
+ const lines = src.split('\n');
113
+ // Skip: comment lines (mentioning `fetch(` in prose is fine) and the one
114
+ // legitimate bare occurrence — the type-signature parameter name
115
+ // `fetch(req: Request): Promise<Response>;` in the `Api`-shaped interface,
116
+ // which is a declaration, not a call.
117
+ const offendingLines = bareFetchCalls
118
+ .map((m) => src.slice(0, m.index).split('\n').length)
119
+ .map((line) => ({ line, text: lines[line - 1] ?? '' }))
120
+ .filter(({ text }) => !/^\s*(\/\/|\*)/.test(text))
121
+ .filter(({ text }) => !/^\s*fetch\(req: Request\): Promise<Response>;/.test(text));
122
+
123
+ expect(offendingLines).toEqual([]);
124
+ });
125
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.53.0",
3
+ "version": "0.53.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,13 +52,13 @@
52
52
  "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
53
53
  },
54
54
  "optionalDependencies": {
55
- "@1agh/maude-darwin-arm64": "0.53.0",
56
- "@1agh/maude-darwin-x64": "0.53.0",
57
- "@1agh/maude-linux-arm64": "0.53.0",
58
- "@1agh/maude-linux-arm64-musl": "0.53.0",
59
- "@1agh/maude-linux-x64": "0.53.0",
60
- "@1agh/maude-linux-x64-musl": "0.53.0",
61
- "@1agh/maude-win32-x64": "0.53.0"
55
+ "@1agh/maude-darwin-arm64": "0.53.1",
56
+ "@1agh/maude-darwin-x64": "0.53.1",
57
+ "@1agh/maude-linux-arm64": "0.53.1",
58
+ "@1agh/maude-linux-arm64-musl": "0.53.1",
59
+ "@1agh/maude-linux-x64": "0.53.1",
60
+ "@1agh/maude-linux-x64-musl": "0.53.1",
61
+ "@1agh/maude-win32-x64": "0.53.1"
62
62
  },
63
63
  "files": [
64
64
  "cli",