ugly-app 0.1.783 → 0.1.784
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/dist/cli/feedbackResolve.d.ts +20 -7
- package/dist/cli/feedbackResolve.d.ts.map +1 -1
- package/dist/cli/feedbackResolve.js +62 -24
- package/dist/cli/feedbackResolve.js.map +1 -1
- package/dist/cli/index.js +46 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/serverLogQueryApi.d.ts +16 -1
- package/dist/cli/serverLogQueryApi.d.ts.map +1 -1
- package/dist/cli/serverLogQueryApi.js +61 -1
- package/dist/cli/serverLogQueryApi.js.map +1 -1
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/server/App.d.ts.map +1 -1
- package/dist/server/App.js +15 -1
- package/dist/server/App.js.map +1 -1
- package/dist/server/TelemetryStore.d.ts +17 -0
- package/dist/server/TelemetryStore.d.ts.map +1 -1
- package/dist/server/TelemetryStore.js +33 -0
- package/dist/server/TelemetryStore.js.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.js +17 -1
- package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
- package/dist/server/adminAllowlist.d.ts +11 -0
- package/dist/server/adminAllowlist.d.ts.map +1 -0
- package/dist/server/adminAllowlist.js +22 -0
- package/dist/server/adminAllowlist.js.map +1 -0
- package/dist/server/feedbackNotify.d.ts +3 -0
- package/dist/server/feedbackNotify.d.ts.map +1 -0
- package/dist/server/feedbackNotify.js +38 -0
- package/dist/server/feedbackNotify.js.map +1 -0
- package/dist/server/feedbackResolveTemplate.d.ts +15 -0
- package/dist/server/feedbackResolveTemplate.d.ts.map +1 -0
- package/dist/server/feedbackResolveTemplate.js +26 -0
- package/dist/server/feedbackResolveTemplate.js.map +1 -0
- package/dist/server/userEmailLookup.d.ts +5 -0
- package/dist/server/userEmailLookup.d.ts.map +1 -0
- package/dist/server/userEmailLookup.js +35 -0
- package/dist/server/userEmailLookup.js.map +1 -0
- package/dist/shared/FrameworkRequests.d.ts +16 -0
- package/dist/shared/FrameworkRequests.d.ts.map +1 -1
- package/dist/shared/FrameworkRequests.js +17 -0
- package/dist/shared/FrameworkRequests.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/feedbackResolve.test.ts +44 -27
- package/src/cli/feedbackResolve.ts +79 -25
- package/src/cli/index.ts +63 -7
- package/src/cli/serverLogQueryApi.ts +88 -1
- package/src/cli/version.ts +1 -1
- package/src/server/App.ts +22 -1
- package/src/server/TelemetryStore.test.ts +33 -0
- package/src/server/TelemetryStore.ts +49 -0
- package/src/server/adapter/workers/createWorkersApp.ts +24 -1
- package/src/server/adminAllowlist.test.ts +29 -0
- package/src/server/adminAllowlist.ts +20 -0
- package/src/server/feedbackNotify.test.ts +61 -0
- package/src/server/feedbackNotify.ts +40 -0
- package/src/server/feedbackResolveTemplate.test.ts +38 -0
- package/src/server/feedbackResolveTemplate.ts +34 -0
- package/src/server/userEmailLookup.test.ts +35 -0
- package/src/server/userEmailLookup.ts +35 -0
- package/src/shared/FrameworkRequests.ts +18 -0
|
@@ -1,56 +1,73 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// The resolve path writes to Cloudflare D1 via execTelemetryD1 — mock it so the
|
|
4
|
-
// unit test asserts the SQL/params contract without hitting the network.
|
|
5
|
-
vi.mock('./telemetryD1.js', () => ({ execTelemetryD1: vi.fn() }));
|
|
6
|
-
|
|
7
|
-
import { execTelemetryD1 } from './telemetryD1.js';
|
|
8
2
|
import {
|
|
9
3
|
parseBatchPayload,
|
|
10
4
|
resolveFeedback,
|
|
11
5
|
resolveFeedbackBatch,
|
|
6
|
+
type ResolveContext,
|
|
12
7
|
} from './feedbackResolve.js';
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
// The resolve path now POSTs to the deployed app's feedbackReportResolve request
|
|
10
|
+
// (which updates D1 + emails the reporter from Worker context). Mock global fetch
|
|
11
|
+
// so the unit test asserts the request contract without hitting the network.
|
|
12
|
+
const ctx: ResolveContext = { appUrl: 'https://app.example', token: 'owner-tok' };
|
|
13
|
+
|
|
14
|
+
function mockFetch(responses: { ok: boolean; body: unknown }[]): void {
|
|
15
|
+
const fn = vi.fn();
|
|
16
|
+
for (const r of responses) {
|
|
17
|
+
fn.mockResolvedValueOnce({
|
|
18
|
+
ok: r.ok,
|
|
19
|
+
status: r.ok ? 200 : 400,
|
|
20
|
+
text: async () => JSON.stringify(r.body),
|
|
21
|
+
json: async () => r.body,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
globalThis.fetch = fn as never;
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
beforeEach(() => {
|
|
17
|
-
|
|
28
|
+
vi.restoreAllMocks();
|
|
18
29
|
});
|
|
19
30
|
|
|
20
31
|
describe('resolveFeedback', () => {
|
|
21
|
-
it('
|
|
22
|
-
|
|
23
|
-
await resolveFeedback({ feedbackId: 'fb-1', status: 'resolved', resolution: 'done' });
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
32
|
+
it('POSTs feedbackReportResolve with id/status/resolution + owner bearer', async () => {
|
|
33
|
+
mockFetch([{ ok: true, body: { ok: true, emailed: true } }]);
|
|
34
|
+
await resolveFeedback({ feedbackId: 'fb-1', status: 'resolved', resolution: 'done' }, ctx);
|
|
35
|
+
const [url, opts] = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
36
|
+
expect(url).toBe('https://app.example/api/feedbackReportResolve');
|
|
37
|
+
expect((opts as { headers: Record<string, string> }).headers.Authorization).toBe('Bearer owner-tok');
|
|
38
|
+
expect(JSON.parse((opts as { body: string }).body)).toEqual({
|
|
39
|
+
input: { id: 'fb-1', status: 'resolved', resolution: 'done' },
|
|
40
|
+
});
|
|
28
41
|
});
|
|
29
42
|
|
|
30
|
-
it('throws when
|
|
31
|
-
|
|
43
|
+
it('throws when the app reports no matching row (ok:false)', async () => {
|
|
44
|
+
mockFetch([{ ok: true, body: { ok: false, emailed: false } }]);
|
|
32
45
|
await expect(
|
|
33
|
-
resolveFeedback({ feedbackId: 'missing', status: 'declined', resolution: 'n/a' }),
|
|
46
|
+
resolveFeedback({ feedbackId: 'missing', status: 'declined', resolution: 'n/a' }, ctx),
|
|
34
47
|
).rejects.toThrow(/no feedback_report row with id "missing"/);
|
|
35
48
|
});
|
|
36
49
|
|
|
37
|
-
it('throws when
|
|
38
|
-
|
|
50
|
+
it('throws when the request fails (non-2xx)', async () => {
|
|
51
|
+
mockFetch([{ ok: false, body: { error: 'Not authorized' } }]);
|
|
39
52
|
await expect(
|
|
40
|
-
resolveFeedback({ feedbackId: 'x', status: 'resolved', resolution: 'y' }),
|
|
53
|
+
resolveFeedback({ feedbackId: 'x', status: 'resolved', resolution: 'y' }, ctx),
|
|
41
54
|
).rejects.toThrow(/Failed to resolve feedback/);
|
|
42
55
|
});
|
|
43
56
|
});
|
|
44
57
|
|
|
45
58
|
describe('resolveFeedbackBatch', () => {
|
|
46
59
|
it('processes every item and reports partial failures without throwing', async () => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const { successes, failures } = await resolveFeedbackBatch([
|
|
51
|
-
{ id: 'a', status: 'resolved', resolution: 'r1' },
|
|
52
|
-
{ id: 'b', status: 'resolved', resolution: 'r2' },
|
|
60
|
+
mockFetch([
|
|
61
|
+
{ ok: true, body: { ok: true, emailed: true } }, // a: ok
|
|
62
|
+
{ ok: true, body: { ok: false, emailed: false } }, // b: missing row
|
|
53
63
|
]);
|
|
64
|
+
const { successes, failures } = await resolveFeedbackBatch(
|
|
65
|
+
[
|
|
66
|
+
{ id: 'a', status: 'resolved', resolution: 'r1' },
|
|
67
|
+
{ id: 'b', status: 'resolved', resolution: 'r2' },
|
|
68
|
+
],
|
|
69
|
+
ctx,
|
|
70
|
+
);
|
|
54
71
|
expect(successes).toBe(1);
|
|
55
72
|
expect(failures).toHaveLength(1);
|
|
56
73
|
expect(failures[0]!.id).toBe('b');
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { resolveProdAuth } from './prodDb.js';
|
|
2
5
|
|
|
3
6
|
export interface FeedbackResolveOptions {
|
|
4
7
|
feedbackId: string;
|
|
@@ -12,47 +15,96 @@ export interface FeedbackResolveItem {
|
|
|
12
15
|
resolution: string;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/** Where + how to reach the deployed app's `feedbackReportResolve` request. */
|
|
19
|
+
export interface ResolveContext {
|
|
20
|
+
/** Deployed app base URL, e.g. https://code.ugly.bot */
|
|
21
|
+
appUrl: string;
|
|
22
|
+
/** Owner bearer token (ugly.bot user token) — verified by the Worker and gated
|
|
23
|
+
* by resolvedIsAdmin (MAINTAIN_BOT_USER_ID or the ADMIN_USER_IDS allowlist). */
|
|
24
|
+
token: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the signed-in owner's ugly.bot token from `~/.ugly-bot/auth.json`. */
|
|
28
|
+
function readOwnerToken(): string {
|
|
29
|
+
const file = path.join(os.homedir(), '.ugly-bot', 'auth.json');
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as { token?: unknown };
|
|
32
|
+
return typeof data.token === 'string' ? data.token : '';
|
|
33
|
+
} catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `{function:'feedbackReportResolve'}` to `ugly.bot/request`, an endpoint that
|
|
20
|
-
* lived on the retired `app` monolith; the live ugly.bot is a billable-op proxy
|
|
21
|
-
* that 400s ("Missing op"), so resolves silently failed for every child app.
|
|
39
|
+
* Resolve the deployed app URL (from publish state) + the owner token needed to
|
|
40
|
+
* authorize `feedbackReportResolve`. Throws with actionable guidance if either
|
|
41
|
+
* is missing.
|
|
22
42
|
*/
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
43
|
+
export function resolveProdResolveContext(): ResolveContext {
|
|
44
|
+
const auth = resolveProdAuth();
|
|
45
|
+
const appUrl = auth.wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, '');
|
|
46
|
+
const token = readOwnerToken();
|
|
47
|
+
if (!token) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'Not signed in to ugly.bot — run `ugly-app login`. feedback:resolve needs your owner token to authorize the resolve on the deployed app.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return { appUrl, token };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a single feedback report by calling the deployed app's admin
|
|
57
|
+
* `feedbackReportResolve` request. The Worker updates the `feedback_report` D1
|
|
58
|
+
* row (status + resolution + resolved_at) AND emails the reporter from the app's
|
|
59
|
+
* own domain — email must run in Worker context, so (unlike the old raw D1
|
|
60
|
+
* UPDATE) the resolve goes through the app rather than the Cloudflare D1 API.
|
|
61
|
+
*/
|
|
62
|
+
async function resolveOne(item: FeedbackResolveItem, ctx: ResolveContext): Promise<void> {
|
|
63
|
+
const res = await fetch(`${ctx.appUrl}/api/feedbackReportResolve`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
Authorization: `Bearer ${ctx.token}`,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
input: { id: item.id, status: item.status, resolution: item.resolution },
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = await res.text().catch(() => '');
|
|
75
|
+
throw new Error(
|
|
76
|
+
`resolve request failed (${res.status})${body ? ` — ${body.slice(0, 200)}` : ''}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const data = (await res.json().catch(() => ({}))) as { ok?: boolean };
|
|
80
|
+
if (!data.ok) {
|
|
29
81
|
throw new Error(`no feedback_report row with id "${item.id}"`);
|
|
30
82
|
}
|
|
31
83
|
}
|
|
32
84
|
|
|
33
85
|
export async function resolveFeedback(
|
|
34
86
|
opts: FeedbackResolveOptions,
|
|
87
|
+
ctx: ResolveContext,
|
|
35
88
|
): Promise<void> {
|
|
36
89
|
try {
|
|
37
|
-
await resolveOne(
|
|
38
|
-
id: opts.feedbackId,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
90
|
+
await resolveOne(
|
|
91
|
+
{ id: opts.feedbackId, status: opts.status, resolution: opts.resolution },
|
|
92
|
+
ctx,
|
|
93
|
+
);
|
|
42
94
|
} catch (e) {
|
|
43
95
|
throw new Error(`Failed to resolve feedback: ${(e as Error).message}`);
|
|
44
96
|
}
|
|
45
97
|
}
|
|
46
98
|
|
|
47
99
|
/**
|
|
48
|
-
* Resolve many feedback items over
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* leave items at `status: new` for a duplicate-process next cycle).
|
|
100
|
+
* Resolve many feedback items over one auth context. Process each item even if
|
|
101
|
+
* earlier ones fail — partial failure is surfaced via the returned `failures`
|
|
102
|
+
* array AND a non-zero exit at the CLI layer, never silently swallowed (the
|
|
103
|
+
* bot-swarm cycle invariant is to fail loud, not leave items at `status: new`).
|
|
53
104
|
*/
|
|
54
105
|
export async function resolveFeedbackBatch(
|
|
55
106
|
items: FeedbackResolveItem[],
|
|
107
|
+
ctx: ResolveContext,
|
|
56
108
|
onItem?: (
|
|
57
109
|
item: FeedbackResolveItem,
|
|
58
110
|
result: { ok: true } | { ok: false; error: string },
|
|
@@ -62,7 +114,7 @@ export async function resolveFeedbackBatch(
|
|
|
62
114
|
let successes = 0;
|
|
63
115
|
for (const item of items) {
|
|
64
116
|
try {
|
|
65
|
-
await resolveOne(item);
|
|
117
|
+
await resolveOne(item, ctx);
|
|
66
118
|
successes++;
|
|
67
119
|
onItem?.(item, { ok: true });
|
|
68
120
|
} catch (e) {
|
|
@@ -120,7 +172,9 @@ export async function readStdinToString(): Promise<string> {
|
|
|
120
172
|
return new Promise((resolve, reject) => {
|
|
121
173
|
let buf = '';
|
|
122
174
|
process.stdin.setEncoding('utf-8');
|
|
123
|
-
process.stdin.on('data', (chunk: string) => {
|
|
175
|
+
process.stdin.on('data', (chunk: string) => {
|
|
176
|
+
buf += chunk;
|
|
177
|
+
});
|
|
124
178
|
process.stdin.on('end', () => resolve(buf));
|
|
125
179
|
process.stdin.on('error', reject);
|
|
126
180
|
process.stdin.resume();
|
package/src/cli/index.ts
CHANGED
|
@@ -301,7 +301,9 @@ function parseSinceToMs(since: string): number | undefined {
|
|
|
301
301
|
|
|
302
302
|
program
|
|
303
303
|
.command('errors')
|
|
304
|
-
.description(
|
|
304
|
+
.description(
|
|
305
|
+
"Query (or, with --clear, delete) error logs from this project's PROD Cloudflare D1 (errorLog)",
|
|
306
|
+
)
|
|
305
307
|
.option('--limit <n>', 'Max entries to show', '50')
|
|
306
308
|
.option('--level <lvl>', 'Filter by level (log/info/warn/error/debug)')
|
|
307
309
|
.option('--source <src>', "Filter by source (e.g. browser, server, coding-task)")
|
|
@@ -310,6 +312,11 @@ program
|
|
|
310
312
|
.option('--since <dur>', 'Only errors newer than this (e.g. 30m, 24h, 7d)')
|
|
311
313
|
.option('--grep <re>', 'Only show entries whose message matches this regex (client-side)')
|
|
312
314
|
.option('--json', 'Output as JSON')
|
|
315
|
+
.option(
|
|
316
|
+
'--clear',
|
|
317
|
+
'Delete matching entries instead of printing them (scoped by --level/--source/--user/--since; clears everything if unscoped)',
|
|
318
|
+
)
|
|
319
|
+
.option('--yes', 'Skip the confirmation prompt when using --clear')
|
|
313
320
|
.action(
|
|
314
321
|
async (opts: {
|
|
315
322
|
limit?: string;
|
|
@@ -320,6 +327,8 @@ program
|
|
|
320
327
|
since?: string;
|
|
321
328
|
grep?: string;
|
|
322
329
|
json?: boolean;
|
|
330
|
+
clear?: boolean;
|
|
331
|
+
yes?: boolean;
|
|
323
332
|
}) => {
|
|
324
333
|
try {
|
|
325
334
|
let sinceMs: number | undefined;
|
|
@@ -330,6 +339,47 @@ program
|
|
|
330
339
|
process.exit(1);
|
|
331
340
|
}
|
|
332
341
|
}
|
|
342
|
+
if (opts.clear) {
|
|
343
|
+
const scope = [
|
|
344
|
+
opts.level ? `level=${opts.level}` : '',
|
|
345
|
+
opts.source ? `source=${opts.source}` : '',
|
|
346
|
+
opts.user ? `user=${opts.user}` : '',
|
|
347
|
+
opts.since ? `newer than ${opts.since}` : '',
|
|
348
|
+
]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(', ');
|
|
351
|
+
const target = scope
|
|
352
|
+
? `error_log entries matching: ${scope}`
|
|
353
|
+
: 'ALL error_log entries (nothing was scoped)';
|
|
354
|
+
if (!opts.yes) {
|
|
355
|
+
const { createInterface } = await import('readline');
|
|
356
|
+
const rl = createInterface({
|
|
357
|
+
input: process.stdin,
|
|
358
|
+
output: process.stdout,
|
|
359
|
+
});
|
|
360
|
+
const answer = await new Promise<string>((resolve) => {
|
|
361
|
+
rl.question(
|
|
362
|
+
`This will permanently delete ${target} from PROD Cloudflare D1. Continue? [y/N] `,
|
|
363
|
+
(a) => {
|
|
364
|
+
rl.close();
|
|
365
|
+
resolve(a.trim().toLowerCase());
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
370
|
+
console.log('Aborted. Nothing was cleared.');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const { clearServerLogsApi } = await import('./serverLogQueryApi.js');
|
|
375
|
+
await clearServerLogsApi('errorLog', {
|
|
376
|
+
level: opts.level,
|
|
377
|
+
source: opts.source,
|
|
378
|
+
userId: opts.user,
|
|
379
|
+
sinceMs,
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
333
383
|
const { queryServerLogsApi } = await import('./serverLogQueryApi.js');
|
|
334
384
|
await queryServerLogsApi('errorLog', {
|
|
335
385
|
limit: opts.limit ? parseInt(opts.limit, 10) : 50,
|
|
@@ -425,7 +475,7 @@ program
|
|
|
425
475
|
|
|
426
476
|
program
|
|
427
477
|
.command('feedback')
|
|
428
|
-
.description("Query feedback from this project's PROD
|
|
478
|
+
.description("Query feedback from this project's PROD Cloudflare D1 (feedbackReport)")
|
|
429
479
|
.option('--limit <n>', 'Max entries to show', '50')
|
|
430
480
|
.option('--json', 'Output as JSON')
|
|
431
481
|
.action(async (opts: { limit?: string; json?: boolean }) => {
|
|
@@ -503,7 +553,9 @@ program
|
|
|
503
553
|
readStdinToString,
|
|
504
554
|
resolveFeedback,
|
|
505
555
|
resolveFeedbackBatch,
|
|
556
|
+
resolveProdResolveContext,
|
|
506
557
|
} = await import('./feedbackResolve.js');
|
|
558
|
+
const ctx = resolveProdResolveContext();
|
|
507
559
|
if (opts.batch) {
|
|
508
560
|
const raw =
|
|
509
561
|
opts.batch === '-'
|
|
@@ -514,6 +566,7 @@ program
|
|
|
514
566
|
const items = parseBatchPayload(raw);
|
|
515
567
|
const { successes, failures } = await resolveFeedbackBatch(
|
|
516
568
|
items,
|
|
569
|
+
ctx,
|
|
517
570
|
(item, result) => {
|
|
518
571
|
if (result.ok) {
|
|
519
572
|
console.log(`✓ ${item.id} ${item.status}`);
|
|
@@ -533,11 +586,14 @@ program
|
|
|
533
586
|
'feedback:resolve requires either --batch <path> or all of --id, --status, --resolution',
|
|
534
587
|
);
|
|
535
588
|
}
|
|
536
|
-
await resolveFeedback(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
589
|
+
await resolveFeedback(
|
|
590
|
+
{
|
|
591
|
+
feedbackId: opts.id,
|
|
592
|
+
status: opts.status as 'resolved' | 'declined',
|
|
593
|
+
resolution: opts.resolution,
|
|
594
|
+
},
|
|
595
|
+
ctx,
|
|
596
|
+
);
|
|
541
597
|
console.log('Feedback resolved.');
|
|
542
598
|
} catch (e) {
|
|
543
599
|
console.error((e as Error).message);
|
|
@@ -91,6 +91,89 @@ async function queryTelemetryD1(
|
|
|
91
91
|
return rows.map((r) => mapD1Row(collection, r));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Delete rows from the project's Cloudflare D1 `error_log`, scoped by the same
|
|
96
|
+
* filters the query path accepts. With no filters this wipes the whole table.
|
|
97
|
+
*
|
|
98
|
+
* Unlike the query path, this does NOT silently exclude the `coding-task`
|
|
99
|
+
* source — "clear" means clear everything that matches, so a bare
|
|
100
|
+
* `ugly-app errors --clear` removes coding-task noise too. Pass `--source` to
|
|
101
|
+
* scope the deletion. Returns the number of rows deleted (D1 `meta.changes`).
|
|
102
|
+
*/
|
|
103
|
+
async function clearTelemetryD1(
|
|
104
|
+
collection: 'errorLog',
|
|
105
|
+
options: {
|
|
106
|
+
level?: string | undefined;
|
|
107
|
+
source?: string | undefined;
|
|
108
|
+
userId?: string | undefined;
|
|
109
|
+
sinceMs?: number | undefined;
|
|
110
|
+
},
|
|
111
|
+
): Promise<number> {
|
|
112
|
+
const d1: ProdTelemetryD1 = resolveProdTelemetryD1();
|
|
113
|
+
|
|
114
|
+
const params: unknown[] = [];
|
|
115
|
+
const conds: string[] = [];
|
|
116
|
+
if (options.level) {
|
|
117
|
+
conds.push('level = ?');
|
|
118
|
+
params.push(options.level);
|
|
119
|
+
}
|
|
120
|
+
if (options.source) {
|
|
121
|
+
conds.push('source = ?');
|
|
122
|
+
params.push(options.source);
|
|
123
|
+
}
|
|
124
|
+
if (options.userId) {
|
|
125
|
+
conds.push('user_id = ?');
|
|
126
|
+
params.push(options.userId);
|
|
127
|
+
}
|
|
128
|
+
if (options.sinceMs !== undefined) {
|
|
129
|
+
conds.push('created > ?');
|
|
130
|
+
params.push(options.sinceMs);
|
|
131
|
+
}
|
|
132
|
+
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
|
133
|
+
const sql = `DELETE FROM error_log ${where}`;
|
|
134
|
+
|
|
135
|
+
const res = await cloudflareD1Fetch(
|
|
136
|
+
d1.projectId,
|
|
137
|
+
`${CLOUDFLARE_API_BASE}/accounts/${d1.accountId}/d1/database/${d1.databaseId}/query`,
|
|
138
|
+
{ method: 'POST', body: JSON.stringify({ sql, params }) },
|
|
139
|
+
);
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
const body = await res.text().catch(() => '');
|
|
142
|
+
// A missing table means there's nothing to clear.
|
|
143
|
+
if (/no such table/i.test(body)) return 0;
|
|
144
|
+
throw new Error(`D1 delete failed (${res.status}): ${body.slice(0, 200)}`);
|
|
145
|
+
}
|
|
146
|
+
const payload = (await res.json()) as {
|
|
147
|
+
result?: { meta?: { changes?: number } }[];
|
|
148
|
+
};
|
|
149
|
+
return payload.result?.[0]?.meta?.changes ?? 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clear (delete) rows from a telemetry-log collection. Only `errorLog` (the
|
|
154
|
+
* Cloudflare D1 `error_log` table) is supported today. Prints the count of
|
|
155
|
+
* rows removed.
|
|
156
|
+
*/
|
|
157
|
+
export async function clearServerLogsApi(
|
|
158
|
+
collection: AdminCollection,
|
|
159
|
+
options: {
|
|
160
|
+
level?: string | undefined;
|
|
161
|
+
source?: string | undefined;
|
|
162
|
+
userId?: string | undefined;
|
|
163
|
+
sinceMs?: number | undefined;
|
|
164
|
+
},
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
if (collection !== 'errorLog') {
|
|
167
|
+
throw new Error(`--clear is only supported for errorLog, not "${collection}".`);
|
|
168
|
+
}
|
|
169
|
+
const deleted = await clearTelemetryD1(collection, options);
|
|
170
|
+
console.log(
|
|
171
|
+
deleted === 0
|
|
172
|
+
? 'No matching entries to clear.'
|
|
173
|
+
: `Cleared ${deleted} ${collection} entr${deleted === 1 ? 'y' : 'ies'}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
94
177
|
const jsonParse = (v: unknown): unknown => {
|
|
95
178
|
if (typeof v !== 'string') return v ?? null;
|
|
96
179
|
try {
|
|
@@ -287,7 +370,11 @@ const formatters: Record<string, (doc: Record<string, unknown>) => string> = {
|
|
|
287
370
|
};
|
|
288
371
|
|
|
289
372
|
/**
|
|
290
|
-
* Query a runtime-log collection from the project's PRODUCTION
|
|
373
|
+
* Query a runtime-log collection from the project's PRODUCTION store.
|
|
374
|
+
*
|
|
375
|
+
* `errorLog`, `feedbackReport`, `workerRun`, and `eventLog` live in the
|
|
376
|
+
* project's Cloudflare D1 telemetry DB and are queried via the D1 HTTP API
|
|
377
|
+
* (see the branch below). Only `perfLog` remains a Neon collection.
|
|
291
378
|
*
|
|
292
379
|
* These logs are written only by the deployed Worker, so we always target the
|
|
293
380
|
* prod connection string persisted by the publish flow — NOT any ambient
|
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
2
|
+
export const CLI_VERSION = "0.1.784";
|
package/src/server/App.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import cookieParser from "cookie-parser";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import type { InboundEmail } from "../shared/InboundEmail.js";
|
|
4
|
+
import { isDefaultAdmin } from "./adminAllowlist.js";
|
|
5
|
+
import { resolveFeedbackReport } from "./TelemetryStore.js";
|
|
6
|
+
import { notifyFeedbackResolved } from "./feedbackNotify.js";
|
|
4
7
|
import { createServer } from "http";
|
|
5
8
|
import type { IncomingMessage } from "http";
|
|
6
9
|
import crypto from "node:crypto";
|
|
@@ -680,7 +683,7 @@ export function createApp<
|
|
|
680
683
|
// Admin gate for the admin-only framework requests. App-provided predicate, else the
|
|
681
684
|
// maintain-bot env id (never allow-all: an unset admin + unset env id denies everyone).
|
|
682
685
|
const resolvedIsAdmin = async (userId: string): Promise<boolean> =>
|
|
683
|
-
isAdminFn ? !!(await isAdminFn(userId, typedDb)) :
|
|
686
|
+
isAdminFn ? !!(await isAdminFn(userId, typedDb)) : isDefaultAdmin(userId, maintainBotUserId);
|
|
684
687
|
const fwHandlers: Record<string, unknown> = {
|
|
685
688
|
// Test-user admin handlers (adminCreateTestUser / list / delete) — shared factory,
|
|
686
689
|
// identical on the Workers adapter. Admin gate = resolvedIsAdmin.
|
|
@@ -817,6 +820,24 @@ export function createApp<
|
|
|
817
820
|
return { items: [] as Record<string, unknown>[] };
|
|
818
821
|
},
|
|
819
822
|
|
|
823
|
+
feedbackReportResolve: async (
|
|
824
|
+
userId: string,
|
|
825
|
+
input: { id: string; status: "resolved" | "declined"; resolution: string },
|
|
826
|
+
) => {
|
|
827
|
+
if (!(await resolvedIsAdmin(userId))) throw new Error("Not authorized");
|
|
828
|
+
// feedback_report + emailSend are Worker-only; on Node/dev resolveFeedbackReport
|
|
829
|
+
// throws "no D1 binding". That's intentional — resolve runs on the deployed Worker.
|
|
830
|
+
const row = await resolveFeedbackReport(
|
|
831
|
+
input.id,
|
|
832
|
+
input.status,
|
|
833
|
+
input.resolution,
|
|
834
|
+
Date.now(),
|
|
835
|
+
);
|
|
836
|
+
if (!row) return { ok: false, emailed: false };
|
|
837
|
+
const emailed = await notifyFeedbackResolved(row, input.status, input.resolution);
|
|
838
|
+
return { ok: true, emailed };
|
|
839
|
+
},
|
|
840
|
+
|
|
820
841
|
submitFeedbackBot: async (
|
|
821
842
|
userId: string,
|
|
822
843
|
input: {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { setTelemetryDb, resolveFeedbackReport, type D1Like } from './TelemetryStore.js';
|
|
3
|
+
|
|
4
|
+
function fakeDb(row: Record<string, unknown> | null, changes: number): D1Like {
|
|
5
|
+
const prepared = {
|
|
6
|
+
bind(..._v: unknown[]) {
|
|
7
|
+
return prepared;
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
return { meta: { changes } };
|
|
11
|
+
},
|
|
12
|
+
async first<T>() {
|
|
13
|
+
return row as T | null;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
prepare: (_sql: string) => prepared,
|
|
18
|
+
batch: async () => undefined,
|
|
19
|
+
} as unknown as D1Like;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('resolveFeedbackReport', () => {
|
|
23
|
+
it('updates the row and returns userId/message/type on success', async () => {
|
|
24
|
+
setTelemetryDb(fakeDb({ user_id: 'u1', message: 'tokens wrong', type: 'bug' }, 1));
|
|
25
|
+
const r = await resolveFeedbackReport('id1', 'resolved', 'fixed', 123);
|
|
26
|
+
expect(r).toEqual({ userId: 'u1', message: 'tokens wrong', type: 'bug' });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null when no row changed', async () => {
|
|
30
|
+
setTelemetryDb(fakeDb(null, 0));
|
|
31
|
+
expect(await resolveFeedbackReport('missing', 'declined', 'n/a', 1)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -28,6 +28,8 @@ const TTL_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
28
28
|
export interface D1PreparedLike {
|
|
29
29
|
bind(...values: unknown[]): D1PreparedLike;
|
|
30
30
|
run(): Promise<unknown>;
|
|
31
|
+
/** Read a single row (used by the feedback-resolve read-back). */
|
|
32
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
31
33
|
}
|
|
32
34
|
export interface D1Like {
|
|
33
35
|
prepare(sql: string): D1PreparedLike;
|
|
@@ -227,6 +229,53 @@ export async function insertTelemetry(
|
|
|
227
229
|
void sweepTtl(db, now);
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
export interface ResolvedFeedbackRow {
|
|
233
|
+
userId: string | null;
|
|
234
|
+
message: string | null;
|
|
235
|
+
type: string | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Mark one feedback report resolved/declined (status + resolution + resolved_at)
|
|
240
|
+
* and read back the fields needed to notify the reporter (their userId + the
|
|
241
|
+
* original message/type). Returns null when no row matched the id.
|
|
242
|
+
*
|
|
243
|
+
* Requires a D1 binding (runs on the deployed Worker); throws when unbound so the
|
|
244
|
+
* caller surfaces "resolve only works on the deployed Worker" rather than silently
|
|
245
|
+
* no-op'ing the way fire-and-forget log writes do.
|
|
246
|
+
*/
|
|
247
|
+
export async function resolveFeedbackReport(
|
|
248
|
+
id: string,
|
|
249
|
+
status: 'resolved' | 'declined',
|
|
250
|
+
resolution: string,
|
|
251
|
+
resolvedAt: number,
|
|
252
|
+
): Promise<ResolvedFeedbackRow | null> {
|
|
253
|
+
const db = _db;
|
|
254
|
+
if (!db) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'[Telemetry] no D1 binding — feedback resolve only works on the deployed Worker',
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
await ensureSchema(db);
|
|
260
|
+
const upd = (await db
|
|
261
|
+
.prepare(
|
|
262
|
+
'UPDATE feedback_report SET status = ?, resolution = ?, resolved_at = ? WHERE id = ?',
|
|
263
|
+
)
|
|
264
|
+
.bind(status, resolution, resolvedAt, id)
|
|
265
|
+
.run()) as { meta?: { changes?: number } };
|
|
266
|
+
if ((upd?.meta?.changes ?? 0) === 0) return null;
|
|
267
|
+
const row = await db
|
|
268
|
+
.prepare('SELECT user_id, message, type FROM feedback_report WHERE id = ?')
|
|
269
|
+
.bind(id)
|
|
270
|
+
.first<Record<string, unknown>>();
|
|
271
|
+
if (!row) return null;
|
|
272
|
+
return {
|
|
273
|
+
userId: (row['user_id'] as string | null) ?? null,
|
|
274
|
+
message: (row['message'] as string | null) ?? null,
|
|
275
|
+
type: (row['type'] as string | null) ?? null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
230
279
|
export interface WorkerRunRecord {
|
|
231
280
|
id?: string;
|
|
232
281
|
name: string;
|
|
@@ -79,6 +79,7 @@ import { flushPendingPublishes } from './pubsub.js';
|
|
|
79
79
|
import { flushErrors, initServerConsoleCapture } from '../../Logging.js';
|
|
80
80
|
import { setEmailBinding, type EmailBinding } from '../../Email.js';
|
|
81
81
|
import { setTelemetryDb, recordWorkerRun } from '../../TelemetryStore.js';
|
|
82
|
+
import { isDefaultAdmin } from '../../adminAllowlist.js';
|
|
82
83
|
import { cronRegistry, workerRegistry } from './schedule.js';
|
|
83
84
|
import { createTypedDB } from '../../DB.js';
|
|
84
85
|
import { createStoreHandlers } from '../../StoreHandlers.js';
|
|
@@ -1617,7 +1618,7 @@ export function createWorkersApp<R extends AppRegistryBase>(
|
|
|
1617
1618
|
} as never);
|
|
1618
1619
|
const maintainBotUserId = process.env['MAINTAIN_BOT_USER_ID'] ?? '';
|
|
1619
1620
|
const resolvedIsAdmin = async (userId: string): Promise<boolean> =>
|
|
1620
|
-
isAdminFn ? !!(await isAdminFn(userId, typedDb)) :
|
|
1621
|
+
isAdminFn ? !!(await isAdminFn(userId, typedDb)) : isDefaultAdmin(userId, maintainBotUserId);
|
|
1621
1622
|
const testUserHandlers = createTestUserHandlers({
|
|
1622
1623
|
typedDb,
|
|
1623
1624
|
userHelper: resolvedWorkersUserHelper,
|
|
@@ -1812,6 +1813,28 @@ export function createWorkersApp<R extends AppRegistryBase>(
|
|
|
1812
1813
|
});
|
|
1813
1814
|
return { ok: true };
|
|
1814
1815
|
},
|
|
1816
|
+
// Admin-only: resolve/decline a feedback report + notify the reporter by email
|
|
1817
|
+
// (from THIS app's own domain). Gated by resolvedIsAdmin. Called by the
|
|
1818
|
+
// `feedback:resolve` CLI. See FrameworkRequests.ts `feedbackReportResolve`.
|
|
1819
|
+
feedbackReportResolve: async (userId, rawInput) => {
|
|
1820
|
+
const input = (rawInput ?? {}) as {
|
|
1821
|
+
id: string;
|
|
1822
|
+
status: 'resolved' | 'declined';
|
|
1823
|
+
resolution: string;
|
|
1824
|
+
};
|
|
1825
|
+
if (!(await resolvedIsAdmin(userId ?? ''))) throw new Error('Not authorized');
|
|
1826
|
+
const { resolveFeedbackReport } = await import('../../TelemetryStore.js');
|
|
1827
|
+
const { notifyFeedbackResolved } = await import('../../feedbackNotify.js');
|
|
1828
|
+
const row = await resolveFeedbackReport(
|
|
1829
|
+
input.id,
|
|
1830
|
+
input.status,
|
|
1831
|
+
input.resolution,
|
|
1832
|
+
Date.now(),
|
|
1833
|
+
);
|
|
1834
|
+
if (!row) return { ok: false, emailed: false };
|
|
1835
|
+
const emailed = await notifyFeedbackResolved(row, input.status, input.resolution);
|
|
1836
|
+
return { ok: true, emailed };
|
|
1837
|
+
},
|
|
1815
1838
|
// Browser error/telemetry capture. Mirrors the Node handler in App.ts —
|
|
1816
1839
|
// writes to the project's own Postgres errorLog via captureClientErrors,
|
|
1817
1840
|
// attaching the recent console snapshot to each error's context.
|