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.
Files changed (61) hide show
  1. package/dist/cli/feedbackResolve.d.ts +20 -7
  2. package/dist/cli/feedbackResolve.d.ts.map +1 -1
  3. package/dist/cli/feedbackResolve.js +62 -24
  4. package/dist/cli/feedbackResolve.js.map +1 -1
  5. package/dist/cli/index.js +46 -5
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/serverLogQueryApi.d.ts +16 -1
  8. package/dist/cli/serverLogQueryApi.d.ts.map +1 -1
  9. package/dist/cli/serverLogQueryApi.js +61 -1
  10. package/dist/cli/serverLogQueryApi.js.map +1 -1
  11. package/dist/cli/version.d.ts +1 -1
  12. package/dist/cli/version.js +1 -1
  13. package/dist/server/App.d.ts.map +1 -1
  14. package/dist/server/App.js +15 -1
  15. package/dist/server/App.js.map +1 -1
  16. package/dist/server/TelemetryStore.d.ts +17 -0
  17. package/dist/server/TelemetryStore.d.ts.map +1 -1
  18. package/dist/server/TelemetryStore.js +33 -0
  19. package/dist/server/TelemetryStore.js.map +1 -1
  20. package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
  21. package/dist/server/adapter/workers/createWorkersApp.js +17 -1
  22. package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
  23. package/dist/server/adminAllowlist.d.ts +11 -0
  24. package/dist/server/adminAllowlist.d.ts.map +1 -0
  25. package/dist/server/adminAllowlist.js +22 -0
  26. package/dist/server/adminAllowlist.js.map +1 -0
  27. package/dist/server/feedbackNotify.d.ts +3 -0
  28. package/dist/server/feedbackNotify.d.ts.map +1 -0
  29. package/dist/server/feedbackNotify.js +38 -0
  30. package/dist/server/feedbackNotify.js.map +1 -0
  31. package/dist/server/feedbackResolveTemplate.d.ts +15 -0
  32. package/dist/server/feedbackResolveTemplate.d.ts.map +1 -0
  33. package/dist/server/feedbackResolveTemplate.js +26 -0
  34. package/dist/server/feedbackResolveTemplate.js.map +1 -0
  35. package/dist/server/userEmailLookup.d.ts +5 -0
  36. package/dist/server/userEmailLookup.d.ts.map +1 -0
  37. package/dist/server/userEmailLookup.js +35 -0
  38. package/dist/server/userEmailLookup.js.map +1 -0
  39. package/dist/shared/FrameworkRequests.d.ts +16 -0
  40. package/dist/shared/FrameworkRequests.d.ts.map +1 -1
  41. package/dist/shared/FrameworkRequests.js +17 -0
  42. package/dist/shared/FrameworkRequests.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/cli/feedbackResolve.test.ts +44 -27
  45. package/src/cli/feedbackResolve.ts +79 -25
  46. package/src/cli/index.ts +63 -7
  47. package/src/cli/serverLogQueryApi.ts +88 -1
  48. package/src/cli/version.ts +1 -1
  49. package/src/server/App.ts +22 -1
  50. package/src/server/TelemetryStore.test.ts +33 -0
  51. package/src/server/TelemetryStore.ts +49 -0
  52. package/src/server/adapter/workers/createWorkersApp.ts +24 -1
  53. package/src/server/adminAllowlist.test.ts +29 -0
  54. package/src/server/adminAllowlist.ts +20 -0
  55. package/src/server/feedbackNotify.test.ts +61 -0
  56. package/src/server/feedbackNotify.ts +40 -0
  57. package/src/server/feedbackResolveTemplate.test.ts +38 -0
  58. package/src/server/feedbackResolveTemplate.ts +34 -0
  59. package/src/server/userEmailLookup.test.ts +35 -0
  60. package/src/server/userEmailLookup.ts +35 -0
  61. 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
- const mockExec = vi.mocked(execTelemetryD1);
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
- mockExec.mockReset();
28
+ vi.restoreAllMocks();
18
29
  });
19
30
 
20
31
  describe('resolveFeedback', () => {
21
- it('UPDATEs feedback_report with status + resolution keyed by id', async () => {
22
- mockExec.mockResolvedValue({ results: [], meta: { changes: 1 } });
23
- await resolveFeedback({ feedbackId: 'fb-1', status: 'resolved', resolution: 'done' });
24
- expect(mockExec).toHaveBeenCalledTimes(1);
25
- const [sql, params] = mockExec.mock.calls[0]!;
26
- expect(sql).toMatch(/UPDATE\s+feedback_report\s+SET\s+status\s*=\s*\?,\s*resolution\s*=\s*\?\s+WHERE\s+id\s*=\s*\?/i);
27
- expect(params).toEqual(['resolved', 'done', 'fb-1']);
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 no row matched the id (changes === 0)', async () => {
31
- mockExec.mockResolvedValue({ results: [], meta: { changes: 0 } });
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 D1 returns no meta', async () => {
38
- mockExec.mockResolvedValue({ results: [], meta: null });
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
- mockExec
48
- .mockResolvedValueOnce({ results: [], meta: { changes: 1 } }) // ok
49
- .mockResolvedValueOnce({ results: [], meta: { changes: 0 } }); // missing row
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 { execTelemetryD1 } from './telemetryD1.js';
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
- * Update a single feedback report's status + resolution directly in the
17
- * project's PROD Cloudflare D1 (`feedback_report` table) the same store the
18
- * `feedback` read CLI queries. The previous implementation POSTed
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
- async function resolveOne(item: FeedbackResolveItem): Promise<void> {
24
- const { meta } = await execTelemetryD1(
25
- 'UPDATE feedback_report SET status = ?, resolution = ? WHERE id = ?',
26
- [item.status, item.resolution, item.id],
27
- );
28
- if (!meta || (meta.changes ?? 0) === 0) {
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
- status: opts.status,
40
- resolution: opts.resolution,
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 a single auth context. Process each
49
- * item even if earlier ones fail — partial failure is surfaced via the
50
- * returned `failures` array AND a non-zero exit at the CLI layer, never
51
- * silently swallowed (the bot-swarm cycle invariant is to fail loud, not
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) => { buf += chunk; });
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("Query error logs from this project's PROD Cloudflare D1 (errorLog)")
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 Neon DB (from publish state)")
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
- feedbackId: opts.id,
538
- status: opts.status as 'resolved' | 'declined',
539
- resolution: opts.resolution,
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 Neon DB.
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
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.783";
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)) : !!maintainBotUserId && userId === maintainBotUserId;
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)) : !!maintainBotUserId && userId === maintainBotUserId;
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.