@1agh/maude 0.50.0 → 0.51.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.
- package/apps/studio/client/panels/CloudBar.jsx +119 -15
- package/apps/studio/client/styles/3-shell-maude.css +2 -0
- package/apps/studio/cloud/endpoints.ts +86 -38
- package/apps/studio/debug-bundle.ts +144 -0
- package/apps/studio/dist/client.bundle.js +902 -897
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +92 -1
- package/apps/studio/sync/workspace-signin.ts +15 -0
- package/apps/studio/test/cloud-endpoints.test.ts +130 -0
- package/apps/studio/test/debug-bundle.test.ts +105 -0
- package/apps/studio/test/workspace-signin.test.ts +19 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +9 -8
|
@@ -14,12 +14,38 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react';
|
|
16
16
|
|
|
17
|
+
import { invoke, isNativeApp, listen } from '../github.js';
|
|
18
|
+
|
|
17
19
|
const api = async (path, init) => {
|
|
18
20
|
const res = await fetch(path, init);
|
|
19
21
|
const json = await res.json().catch(() => ({}));
|
|
20
22
|
return { ok: res.ok, status: res.status, json };
|
|
21
23
|
};
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Parse a maude://open/<project>?code=mhc_… deep link. Untrusted input — a
|
|
27
|
+
* drive-by page can navigate to maude:// — so nothing here is acted on
|
|
28
|
+
* without the person pressing Connect, and the code is only ever exchanged
|
|
29
|
+
* against the app's own configured Maude Cloud address.
|
|
30
|
+
*/
|
|
31
|
+
export function parseDeepLink(url) {
|
|
32
|
+
const m = /^maude:\/\/open\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?\?(.*)$/.exec(String(url ?? ''));
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const code = new URLSearchParams(m[2]).get('code') ?? '';
|
|
35
|
+
if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
|
|
36
|
+
return { project: m[1], code };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The share view's address for a project cell URL (viewer's browser home). */
|
|
40
|
+
export function shareViewUrl(projectUrl, projectId) {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(projectUrl);
|
|
43
|
+
return `${u.protocol}//view-${u.host}`;
|
|
44
|
+
} catch {
|
|
45
|
+
return `https://view-${projectId}.cloud.maude.sh`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
function Spark({ size = 15 }) {
|
|
24
50
|
return (
|
|
25
51
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
|
|
@@ -76,6 +102,7 @@ export default function CloudBar() {
|
|
|
76
102
|
const [note, setNote] = useState('');
|
|
77
103
|
const [error, setError] = useState('');
|
|
78
104
|
const [copied, setCopied] = useState(false);
|
|
105
|
+
const [pending, setPending] = useState(null); // { project, code } from maude://
|
|
79
106
|
const railRef = useRef(null);
|
|
80
107
|
const pollRef = useRef(null);
|
|
81
108
|
|
|
@@ -89,6 +116,43 @@ export default function CloudBar() {
|
|
|
89
116
|
return () => clearInterval(pollRef.current);
|
|
90
117
|
}, []);
|
|
91
118
|
|
|
119
|
+
// maude:// deep links (Phase 17) — native shell only. The link that LAUNCHED
|
|
120
|
+
// the app is parked in the shell (take once); links while running arrive as
|
|
121
|
+
// events. Either way: ask, never auto-connect.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!isNativeApp()) return undefined;
|
|
124
|
+
const consume = (url) => {
|
|
125
|
+
const parsed = parseDeepLink(url);
|
|
126
|
+
if (parsed) setPending(parsed);
|
|
127
|
+
};
|
|
128
|
+
invoke('take_pending_deep_link')
|
|
129
|
+
.then((url) => url && consume(url))
|
|
130
|
+
.catch(() => {});
|
|
131
|
+
let unlisten = null;
|
|
132
|
+
listen('maude://deep-link', consume).then((fn) => {
|
|
133
|
+
unlisten = fn;
|
|
134
|
+
});
|
|
135
|
+
return () => unlisten?.();
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
async function connectPending() {
|
|
139
|
+
if (!pending) return;
|
|
140
|
+
setBusy('deeplink');
|
|
141
|
+
setError('');
|
|
142
|
+
const r = await api('/_api/cloud/attach/code', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({ code: pending.code }),
|
|
146
|
+
});
|
|
147
|
+
setBusy('');
|
|
148
|
+
setPending(null);
|
|
149
|
+
if (r.ok && r.json?.ok) {
|
|
150
|
+
setNote(`Linked to ${r.json.project ?? pending.project} — restart the studio server to start syncing.`);
|
|
151
|
+
} else {
|
|
152
|
+
setError(r.json?.error || 'The workspace could not be connected.');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
92
156
|
useEffect(() => {
|
|
93
157
|
if (!menuOpen) return undefined;
|
|
94
158
|
const onDoc = (e) => {
|
|
@@ -186,6 +250,28 @@ export default function CloudBar() {
|
|
|
186
250
|
|
|
187
251
|
return (
|
|
188
252
|
<div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
|
|
253
|
+
{pending && (
|
|
254
|
+
<div className="gi-rail-confirm" data-testid="cloud-deeplink">
|
|
255
|
+
<span className="gi-rail-hint">Connect this project to {pending.project}?</span>
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
className="btn btn--sm"
|
|
259
|
+
disabled={busy === 'deeplink'}
|
|
260
|
+
onClick={connectPending}
|
|
261
|
+
data-testid="cloud-deeplink-connect"
|
|
262
|
+
>
|
|
263
|
+
{busy === 'deeplink' ? 'Connecting…' : 'Connect'}
|
|
264
|
+
</button>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className="btn btn--ghost btn--sm"
|
|
268
|
+
onClick={() => setPending(null)}
|
|
269
|
+
data-testid="cloud-deeplink-dismiss"
|
|
270
|
+
>
|
|
271
|
+
Not now
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
189
275
|
{state === 'out' && (
|
|
190
276
|
<>
|
|
191
277
|
<button type="button" className="btn btn--ghost btn--sm gi-rail-signin" onClick={startSignIn} data-testid="cloud-signin">
|
|
@@ -226,21 +312,39 @@ export default function CloudBar() {
|
|
|
226
312
|
<div className="gi-menu-item" aria-disabled="true">No projects yet — start one on the dashboard.</div>
|
|
227
313
|
)}
|
|
228
314
|
{Array.isArray(projects) &&
|
|
229
|
-
projects.map((p) =>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
315
|
+
projects.map((p) =>
|
|
316
|
+
p.role === 'viewer' ? (
|
|
317
|
+
// Viewer dignity (Phase 17 T4): no dead Connect that would
|
|
318
|
+
// 403 at the workspace — viewing has a first-class home in
|
|
319
|
+
// the browser, and the label says why.
|
|
320
|
+
<button
|
|
321
|
+
key={p.id}
|
|
322
|
+
type="button"
|
|
323
|
+
className="gi-menu-item"
|
|
324
|
+
role="menuitem"
|
|
325
|
+
onClick={() => window.open(shareViewUrl(p.url, p.id), '_blank', 'noopener')}
|
|
326
|
+
title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
|
|
327
|
+
data-testid={`cloud-project-${p.id}`}
|
|
328
|
+
>
|
|
329
|
+
<Icon name="external" size={15} /> View {p.name || p.id} in the browser
|
|
330
|
+
<span className="gi-menu-login" style={{ marginLeft: 'auto' }}>viewer</span>
|
|
331
|
+
</button>
|
|
332
|
+
) : (
|
|
333
|
+
<button
|
|
334
|
+
key={p.id}
|
|
335
|
+
type="button"
|
|
336
|
+
className="gi-menu-item"
|
|
337
|
+
role="menuitem"
|
|
338
|
+
disabled={busy === p.id}
|
|
339
|
+
onClick={() => connect(p.id)}
|
|
340
|
+
title={`Connect this project to ${p.url} (${p.role})`}
|
|
341
|
+
data-testid={`cloud-project-${p.id}`}
|
|
342
|
+
>
|
|
343
|
+
<Icon name="link" size={15} /> {busy === p.id ? 'Connecting…' : `Connect ${p.name || p.id}`}
|
|
344
|
+
<span className="gi-menu-login" style={{ marginLeft: 'auto' }}>{p.stateLabel}</span>
|
|
345
|
+
</button>
|
|
346
|
+
)
|
|
347
|
+
)}
|
|
244
348
|
<div className="gi-menu-sep" />
|
|
245
349
|
<button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
|
|
246
350
|
<Icon name="external" size={15} /> Open the dashboard
|
|
@@ -1993,6 +1993,8 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1993
1993
|
.gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
|
|
1994
1994
|
.gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); }
|
|
1995
1995
|
.gi-rail-signin { width: 100%; justify-content: center; }
|
|
1996
|
+
.gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
|
|
1997
|
+
.gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; }
|
|
1996
1998
|
.gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1997
1999
|
.gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
|
|
1998
2000
|
.gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
|
|
@@ -189,48 +189,96 @@ export function createCloudEndpoints(ctx: Ctx) {
|
|
|
189
189
|
if (opened.status !== 200 || !opened.body?.token) {
|
|
190
190
|
return { status: 502, json: { ok: false, error: 'The project could not be opened with this account.' } };
|
|
191
191
|
}
|
|
192
|
+
return linkToWorkspace({ workspaceUrl: opened.body.url, projectToken: opened.body.token, role: opened.body.role });
|
|
193
|
+
},
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (res.ok && body?.token) hubToken = body.token;
|
|
204
|
-
} catch {
|
|
205
|
-
/* handled below */
|
|
195
|
+
/**
|
|
196
|
+
* Attach via a one-time handoff code — the maude:// lane (Phase 17). The
|
|
197
|
+
* code came from an untrusted URL, so it is exchanged ONLY against the
|
|
198
|
+
* configured Maude Cloud address (never one the link names), and the
|
|
199
|
+
* workspace we then link is the one THAT exchange returns. Needs no
|
|
200
|
+
* personal token: the code itself proves the dashboard session.
|
|
201
|
+
*/
|
|
202
|
+
async attachCode(code: string): Promise<CloudEndpointResult> {
|
|
203
|
+
if (typeof code !== 'string' || !/^mhc_[0-9a-f]{16,128}$/.test(code)) {
|
|
204
|
+
return { status: 400, json: { ok: false, error: 'That link is not valid.' } };
|
|
206
205
|
}
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
const exchanged = await cloudFetch('/auth/handoff/exchange', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({ code }),
|
|
210
|
+
});
|
|
211
|
+
if (exchanged.status !== 200 || !exchanged.body?.token) {
|
|
212
|
+
return {
|
|
213
|
+
status: 410,
|
|
214
|
+
json: {
|
|
215
|
+
ok: false,
|
|
216
|
+
error:
|
|
217
|
+
'That link expired — it works once, for two minutes. Press “Open in Maude” on the project page again.',
|
|
218
|
+
},
|
|
219
|
+
};
|
|
209
220
|
}
|
|
221
|
+
return linkToWorkspace({
|
|
222
|
+
workspaceUrl: exchanged.body.url,
|
|
223
|
+
projectToken: exchanged.body.token,
|
|
224
|
+
role: exchanged.body.role,
|
|
225
|
+
project: exchanged.body.project,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
};
|
|
210
229
|
|
|
211
|
-
|
|
212
|
-
|
|
230
|
+
/** The shared tail of every attach: cell exchange → credential + linkedHub. */
|
|
231
|
+
async function linkToWorkspace({
|
|
232
|
+
workspaceUrl,
|
|
233
|
+
projectToken,
|
|
234
|
+
role,
|
|
235
|
+
project,
|
|
236
|
+
}: {
|
|
237
|
+
workspaceUrl: string;
|
|
238
|
+
projectToken: string;
|
|
239
|
+
role?: string;
|
|
240
|
+
project?: string;
|
|
241
|
+
}): Promise<CloudEndpointResult> {
|
|
242
|
+
// The cell is a different host than the control plane — one direct call.
|
|
243
|
+
let hubToken: string | null = null;
|
|
244
|
+
try {
|
|
245
|
+
const res = await fetch(`${workspaceUrl}/auth/login`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'content-type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ token: projectToken }),
|
|
249
|
+
});
|
|
250
|
+
const body: any = await res.json().catch(() => ({}));
|
|
251
|
+
if (res.ok && body?.token) hubToken = body.token;
|
|
252
|
+
} catch {
|
|
253
|
+
/* handled below */
|
|
254
|
+
}
|
|
255
|
+
if (!hubToken) {
|
|
256
|
+
return { status: 502, json: { ok: false, error: 'The workspace did not accept the sign-in. Try again in a minute.' } };
|
|
257
|
+
}
|
|
213
258
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
let cfg: any = {};
|
|
217
|
-
try {
|
|
218
|
-
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
219
|
-
} catch {
|
|
220
|
-
/* absent/malformed → start minimal */
|
|
221
|
-
}
|
|
222
|
-
cfg.linkedHub = { url: norm, linkedAt: Date.now() };
|
|
223
|
-
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
259
|
+
const norm = normalizeUrl(workspaceUrl);
|
|
260
|
+
saveHubCredential(norm, hubToken);
|
|
224
261
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
262
|
+
// Project side — linkedHub in .design/config.json (committed; no token).
|
|
263
|
+
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
264
|
+
let cfg: any = {};
|
|
265
|
+
try {
|
|
266
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
267
|
+
} catch {
|
|
268
|
+
/* absent/malformed → start minimal */
|
|
269
|
+
}
|
|
270
|
+
cfg.linkedHub = { url: norm, linkedAt: Date.now() };
|
|
271
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
status: 200,
|
|
275
|
+
json: {
|
|
276
|
+
ok: true,
|
|
277
|
+
url: norm,
|
|
278
|
+
role: role ?? null,
|
|
279
|
+
project: project ?? null,
|
|
280
|
+
note: 'Linked. Restart the studio server to start syncing.',
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
236
284
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// debug-bundle.ts — scrubbed diagnostic bundle for the Report-a-Bug dialog
|
|
2
|
+
// (feature-bug-report-button). Three pieces:
|
|
3
|
+
//
|
|
4
|
+
// 1. a 500-line in-memory log ring fed by a console tap (installLogRing) —
|
|
5
|
+
// the dev-server has no persistent log of its own, so the ring is what
|
|
6
|
+
// gives a report something to attach;
|
|
7
|
+
// 2. scrub() — the DETERMINISTIC redaction pass every log line goes through
|
|
8
|
+
// before it can leave the process. Privacy invariant (docs/report-schema.md):
|
|
9
|
+
// the scrubber runs server-side, has red/green tests, and is extended
|
|
10
|
+
// test-first — never "best-effort";
|
|
11
|
+
// 3. buildDebugBundle() — assembles the `maude-report/v1` server-side fields.
|
|
12
|
+
//
|
|
13
|
+
// The consuming route (`GET /_api/debug-bundle`, http.ts) is PRIVILEGED:
|
|
14
|
+
// main-origin only, absent from CANVAS_SAFE_API + startCanvasServer's routes
|
|
15
|
+
// (dual-allowlist rule, DDR-088) — a hub-pushed canvas must never read logs.
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const RING_MAX = 500;
|
|
20
|
+
const ring: string[] = [];
|
|
21
|
+
let tapInstalled = false;
|
|
22
|
+
|
|
23
|
+
/** Append one line to the ring (public for tests + non-console callers). */
|
|
24
|
+
export function recordLogLine(line: string): void {
|
|
25
|
+
ring.push(line);
|
|
26
|
+
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Last `n` raw (UNSCRUBBED — caller must scrub) ring lines, oldest first. */
|
|
30
|
+
export function logRingLines(n = 200): string[] {
|
|
31
|
+
return ring.slice(-n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmt(args: unknown[]): string {
|
|
35
|
+
return args
|
|
36
|
+
.map((a) => {
|
|
37
|
+
if (typeof a === 'string') return a;
|
|
38
|
+
if (a instanceof Error) return a.stack ?? a.message;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(a);
|
|
41
|
+
} catch {
|
|
42
|
+
return String(a);
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mirror console.log/warn/error into the ring. Original behavior preserved
|
|
50
|
+
* (stdout/stderr still get every line). Idempotent — safe to call from both
|
|
51
|
+
* the server boot and tests.
|
|
52
|
+
*/
|
|
53
|
+
export function installLogRing(): void {
|
|
54
|
+
if (tapInstalled) return;
|
|
55
|
+
tapInstalled = true;
|
|
56
|
+
for (const level of ['log', 'warn', 'error'] as const) {
|
|
57
|
+
const original = console[level].bind(console);
|
|
58
|
+
console[level] = (...args: unknown[]) => {
|
|
59
|
+
recordLogLine(`[${level}] ${fmt(args)}`);
|
|
60
|
+
original(...args);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScrubOptions {
|
|
66
|
+
/** Absolute repo root → rewritten to `<project>/`. */
|
|
67
|
+
repoRoot?: string;
|
|
68
|
+
/** Home dir → rewritten to `~`. Defaults to os.homedir(). */
|
|
69
|
+
home?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deterministic redaction. Order matters: repoRoot first (it usually lives
|
|
74
|
+
* under home — scrubbing home first would leave `~/git/repo` variants that
|
|
75
|
+
* the repoRoot rule no longer matches), then home, then secret material,
|
|
76
|
+
* then emails.
|
|
77
|
+
*/
|
|
78
|
+
export function scrub(text: string, opts: ScrubOptions = {}): string {
|
|
79
|
+
let out = text;
|
|
80
|
+
|
|
81
|
+
const repoRoot = opts.repoRoot?.replace(/\/+$/, '');
|
|
82
|
+
if (repoRoot) out = out.replaceAll(repoRoot, '<project>');
|
|
83
|
+
|
|
84
|
+
const home = (opts.home ?? homedir()).replace(/\/+$/, '');
|
|
85
|
+
if (home) out = out.replaceAll(home, '~');
|
|
86
|
+
|
|
87
|
+
// Known token shapes first (GitHub classic + fine-grained, Anthropic, JWTs).
|
|
88
|
+
out = out.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}\b/g, '[redacted]');
|
|
89
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]');
|
|
90
|
+
out = out.replace(
|
|
91
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g,
|
|
92
|
+
'[redacted]'
|
|
93
|
+
);
|
|
94
|
+
// Bearer headers, however they were logged.
|
|
95
|
+
out = out.replace(/\b(Bearer)\s+[^\s"']+/gi, '$1 [redacted]');
|
|
96
|
+
// key=value / "key": "value" forms for secret-shaped keys — keep the key,
|
|
97
|
+
// redact the value.
|
|
98
|
+
out = out.replace(
|
|
99
|
+
/\b(token|secret|password|passwd|api[_-]?key|authorization|access[_-]?key)("?\s*[:=]\s*"?)[^\s"',}]+/gi,
|
|
100
|
+
'$1$2[redacted]'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
out = out.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]');
|
|
104
|
+
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DebugBundle {
|
|
109
|
+
app: { maudeVersion: string; platform: string; arch: string };
|
|
110
|
+
context: { projectName: string | null; activeCanvas: string | null };
|
|
111
|
+
logs: { serverLogTail: string };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Assemble the server-side half of a `maude-report/v1` payload. Everything
|
|
116
|
+
* here is ALREADY scrubbed — the client treats the bundle as display+consent
|
|
117
|
+
* material and must not need to re-clean it. Canvas CONTENT never enters the
|
|
118
|
+
* bundle: `activeCanvas` is the relative path only.
|
|
119
|
+
*/
|
|
120
|
+
export function buildDebugBundle(args: {
|
|
121
|
+
maudeVersion: string;
|
|
122
|
+
projectName: string | null;
|
|
123
|
+
activeCanvas: string | null;
|
|
124
|
+
repoRoot: string;
|
|
125
|
+
tailLines?: number;
|
|
126
|
+
}): DebugBundle {
|
|
127
|
+
const scrubOpts: ScrubOptions = { repoRoot: args.repoRoot };
|
|
128
|
+
return {
|
|
129
|
+
app: {
|
|
130
|
+
maudeVersion: args.maudeVersion,
|
|
131
|
+
platform: process.platform,
|
|
132
|
+
arch: process.arch,
|
|
133
|
+
},
|
|
134
|
+
context: {
|
|
135
|
+
projectName: args.projectName,
|
|
136
|
+
activeCanvas: args.activeCanvas ? scrub(args.activeCanvas, scrubOpts) : null,
|
|
137
|
+
},
|
|
138
|
+
logs: {
|
|
139
|
+
serverLogTail: logRingLines(args.tailLines ?? 200)
|
|
140
|
+
.map((l) => scrub(l, scrubOpts))
|
|
141
|
+
.join('\n'),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|