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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { isDefaultAdmin } from './adminAllowlist.js';
|
|
3
|
+
|
|
4
|
+
const prev = process.env['ADMIN_USER_IDS'];
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
if (prev === undefined) delete process.env['ADMIN_USER_IDS'];
|
|
7
|
+
else process.env['ADMIN_USER_IDS'] = prev;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('isDefaultAdmin', () => {
|
|
11
|
+
it('admits the maintain-bot user', () => {
|
|
12
|
+
delete process.env['ADMIN_USER_IDS'];
|
|
13
|
+
expect(isDefaultAdmin('bot1', 'bot1')).toBe(true);
|
|
14
|
+
expect(isDefaultAdmin('other', 'bot1')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('admits users in the ADMIN_USER_IDS allowlist', () => {
|
|
18
|
+
process.env['ADMIN_USER_IDS'] = 'owner1, owner2';
|
|
19
|
+
expect(isDefaultAdmin('owner2', 'bot1')).toBe(true);
|
|
20
|
+
expect(isDefaultAdmin('owner1', '')).toBe(true);
|
|
21
|
+
expect(isDefaultAdmin('nobody', 'bot1')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('denies everyone when both are unset/empty', () => {
|
|
25
|
+
delete process.env['ADMIN_USER_IDS'];
|
|
26
|
+
expect(isDefaultAdmin('anyone', '')).toBe(false);
|
|
27
|
+
expect(isDefaultAdmin('', '')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default admin gate for admin-only framework requests when the app supplies no
|
|
3
|
+
* `isAdminFn`. A user is admin when they are the maintain-bot user
|
|
4
|
+
* (`MAINTAIN_BOT_USER_ID`) OR listed in the `ADMIN_USER_IDS` env allowlist
|
|
5
|
+
* (comma-separated stable userIds). The allowlist lets a project grant its owner
|
|
6
|
+
* admin — e.g. so the owner can resolve feedback from the CLI — without shipping
|
|
7
|
+
* a custom `isAdminFn`. Never allow-all: with both unset, everyone is denied.
|
|
8
|
+
*/
|
|
9
|
+
export function envAdminUserIds(): string[] {
|
|
10
|
+
return (process.env['ADMIN_USER_IDS'] ?? '')
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((s) => s.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isDefaultAdmin(userId: string, maintainBotUserId: string): boolean {
|
|
17
|
+
if (!userId) return false;
|
|
18
|
+
if (maintainBotUserId && userId === maintainBotUserId) return true;
|
|
19
|
+
return envAdminUserIds().includes(userId);
|
|
20
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('./userEmailLookup.js', () => ({ resolveUserEmail: vi.fn() }));
|
|
4
|
+
vi.mock('./Email.js', () => ({ emailSend: vi.fn() }));
|
|
5
|
+
|
|
6
|
+
import { notifyFeedbackResolved } from './feedbackNotify.js';
|
|
7
|
+
import { resolveUserEmail } from './userEmailLookup.js';
|
|
8
|
+
import { emailSend } from './Email.js';
|
|
9
|
+
|
|
10
|
+
const mockResolve = vi.mocked(resolveUserEmail);
|
|
11
|
+
const mockSend = vi.mocked(emailSend);
|
|
12
|
+
|
|
13
|
+
describe('notifyFeedbackResolved', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns false and sends nothing for an anonymous report (no userId)', async () => {
|
|
19
|
+
const sent = await notifyFeedbackResolved(
|
|
20
|
+
{ userId: null, message: null, type: null },
|
|
21
|
+
'resolved',
|
|
22
|
+
'x',
|
|
23
|
+
);
|
|
24
|
+
expect(sent).toBe(false);
|
|
25
|
+
expect(mockSend).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false when the address cannot be resolved', async () => {
|
|
29
|
+
mockResolve.mockResolvedValue(null);
|
|
30
|
+
const sent = await notifyFeedbackResolved(
|
|
31
|
+
{ userId: 'u', message: 'm', type: 'bug' },
|
|
32
|
+
'resolved',
|
|
33
|
+
'x',
|
|
34
|
+
);
|
|
35
|
+
expect(sent).toBe(false);
|
|
36
|
+
expect(mockSend).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sends to the resolved address and returns true', async () => {
|
|
40
|
+
mockResolve.mockResolvedValue('a@b.co');
|
|
41
|
+
mockSend.mockResolvedValue(undefined);
|
|
42
|
+
const sent = await notifyFeedbackResolved(
|
|
43
|
+
{ userId: 'u', message: 'm', type: 'bug' },
|
|
44
|
+
'resolved',
|
|
45
|
+
'fixed',
|
|
46
|
+
);
|
|
47
|
+
expect(sent).toBe(true);
|
|
48
|
+
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@b.co' }));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is best-effort: returns false when emailSend throws', async () => {
|
|
52
|
+
mockResolve.mockResolvedValue('a@b.co');
|
|
53
|
+
mockSend.mockRejectedValue(new Error('no EMAIL binding'));
|
|
54
|
+
const sent = await notifyFeedbackResolved(
|
|
55
|
+
{ userId: 'u', message: 'm', type: 'bug' },
|
|
56
|
+
'declined',
|
|
57
|
+
'no',
|
|
58
|
+
);
|
|
59
|
+
expect(sent).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notify a feedback reporter that their report was resolved/declined. Looks up
|
|
3
|
+
* the reporter's email by userId via ugly.bot (the identity provider) and sends
|
|
4
|
+
* the notice from the app's OWN domain via the framework email binding. This is
|
|
5
|
+
* "each app sends its own email" — ugly.bot is only the userId→email resolver.
|
|
6
|
+
*
|
|
7
|
+
* Best-effort: returns whether an email was actually sent; never throws, so a
|
|
8
|
+
* missing address, an anonymous report, or an email failure never blocks the
|
|
9
|
+
* resolve itself.
|
|
10
|
+
*/
|
|
11
|
+
import { emailSend } from './Email.js';
|
|
12
|
+
import { renderFeedbackResolveEmail } from './feedbackResolveTemplate.js';
|
|
13
|
+
import { resolveUserEmail } from './userEmailLookup.js';
|
|
14
|
+
import type { ResolvedFeedbackRow } from './TelemetryStore.js';
|
|
15
|
+
|
|
16
|
+
export async function notifyFeedbackResolved(
|
|
17
|
+
row: ResolvedFeedbackRow,
|
|
18
|
+
status: 'resolved' | 'declined',
|
|
19
|
+
resolution: string,
|
|
20
|
+
): Promise<boolean> {
|
|
21
|
+
if (!row.userId) return false;
|
|
22
|
+
const to = await resolveUserEmail(row.userId);
|
|
23
|
+
if (!to) return false;
|
|
24
|
+
const { subject, html } = renderFeedbackResolveEmail({
|
|
25
|
+
status,
|
|
26
|
+
resolution,
|
|
27
|
+
originalMessage: row.message,
|
|
28
|
+
type: row.type,
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
await emailSend({ to, subject, html });
|
|
32
|
+
return true;
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error('[feedbackNotify] send failed', {
|
|
35
|
+
userId: row.userId,
|
|
36
|
+
error: e instanceof Error ? e.message : String(e),
|
|
37
|
+
});
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderFeedbackResolveEmail } from './feedbackResolveTemplate.js';
|
|
3
|
+
|
|
4
|
+
describe('renderFeedbackResolveEmail', () => {
|
|
5
|
+
it('resolved: subject + includes resolution and original report text', () => {
|
|
6
|
+
const { subject, html } = renderFeedbackResolveEmail({
|
|
7
|
+
status: 'resolved',
|
|
8
|
+
resolution: 'Fixed in 0.2.0',
|
|
9
|
+
originalMessage: 'tokens wrong',
|
|
10
|
+
type: 'bug',
|
|
11
|
+
});
|
|
12
|
+
expect(subject).toMatch(/resolved/i);
|
|
13
|
+
expect(html).toContain('Fixed in 0.2.0');
|
|
14
|
+
expect(html).toContain('tokens wrong');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('declined: subject reflects the status', () => {
|
|
18
|
+
const { subject } = renderFeedbackResolveEmail({
|
|
19
|
+
status: 'declined',
|
|
20
|
+
resolution: 'wontfix',
|
|
21
|
+
originalMessage: null,
|
|
22
|
+
type: null,
|
|
23
|
+
});
|
|
24
|
+
expect(subject).toMatch(/declined/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('escapes HTML in user-supplied text', () => {
|
|
28
|
+
const { html } = renderFeedbackResolveEmail({
|
|
29
|
+
status: 'resolved',
|
|
30
|
+
resolution: '<script>x</script>',
|
|
31
|
+
originalMessage: 'a & b <c>',
|
|
32
|
+
type: 'bug',
|
|
33
|
+
});
|
|
34
|
+
expect(html).not.toContain('<script>');
|
|
35
|
+
expect(html).toContain('<script>');
|
|
36
|
+
expect(html).toContain('a & b <c>');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional email a child app sends (from its own domain) to a user when
|
|
3
|
+
* their feedback report is resolved or declined. Kept intentionally minimal —
|
|
4
|
+
* the app owns branding; this is a plain, escaped notification.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function esc(s: string): string {
|
|
8
|
+
return s
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderFeedbackResolveEmail(input: {
|
|
15
|
+
status: 'resolved' | 'declined';
|
|
16
|
+
resolution: string;
|
|
17
|
+
originalMessage: string | null;
|
|
18
|
+
type: string | null;
|
|
19
|
+
}): { subject: string; html: string } {
|
|
20
|
+
const verb = input.status === 'resolved' ? 'resolved' : 'declined';
|
|
21
|
+
const subject = `Your feedback was ${verb}`;
|
|
22
|
+
const orig = input.originalMessage
|
|
23
|
+
? `<p style="color:#666"><em>Your report${
|
|
24
|
+
input.type ? ` (${esc(input.type)})` : ''
|
|
25
|
+
}:</em><br>${esc(input.originalMessage)}</p>`
|
|
26
|
+
: '';
|
|
27
|
+
const html = `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:520px;line-height:1.5">
|
|
28
|
+
<h2 style="margin:0 0 12px">Your feedback was ${verb}</h2>
|
|
29
|
+
${orig}
|
|
30
|
+
<p>${esc(input.resolution)}</p>
|
|
31
|
+
<p style="color:#999;font-size:12px;margin-top:24px">You're receiving this because you filed feedback in the app.</p>
|
|
32
|
+
</div>`;
|
|
33
|
+
return { subject, html };
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { resolveUserEmail } from './userEmailLookup.js';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const base = 'https://ugly.bot';
|
|
9
|
+
|
|
10
|
+
describe('resolveUserEmail', () => {
|
|
11
|
+
it('returns the email ugly.bot resolves for the userId', async () => {
|
|
12
|
+
globalThis.fetch = vi
|
|
13
|
+
.fn()
|
|
14
|
+
.mockResolvedValue({ ok: true, json: async () => ({ email: 'a@b.co' }) }) as never;
|
|
15
|
+
expect(await resolveUserEmail('u1', { token: 't', baseUrl: base })).toBe('a@b.co');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns null when the user has no email', async () => {
|
|
19
|
+
globalThis.fetch = vi
|
|
20
|
+
.fn()
|
|
21
|
+
.mockResolvedValue({ ok: true, json: async () => ({ email: null }) }) as never;
|
|
22
|
+
expect(await resolveUserEmail('u1', { token: 't', baseUrl: base })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns null on a non-ok response (best-effort)', async () => {
|
|
26
|
+
globalThis.fetch = vi
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValue({ ok: false, json: async () => ({}) }) as never;
|
|
29
|
+
expect(await resolveUserEmail('u1', { token: 't', baseUrl: base })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns null without a token', async () => {
|
|
33
|
+
expect(await resolveUserEmail('u1', { token: '', baseUrl: base })).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a user's email address by their stable userId via ugly.bot's identity
|
|
3
|
+
* service (`POST /v1/users/email`). ugly.bot owns identity + email, so this is
|
|
4
|
+
* the only reliable userId→email source in the default `uglybot` auth mode
|
|
5
|
+
* (the app's own `authIdentity` table is empty there). Used to notify a user
|
|
6
|
+
* about their OWN feedback — the app then sends the mail from its own domain.
|
|
7
|
+
*
|
|
8
|
+
* Best-effort: returns null on any failure or when the user has no email on
|
|
9
|
+
* record (phone-only / legacy accounts). Callers skip the email in that case.
|
|
10
|
+
*/
|
|
11
|
+
import { getUglyBotUrl } from '../shared/uglyBotUrl.js';
|
|
12
|
+
|
|
13
|
+
export async function resolveUserEmail(
|
|
14
|
+
userId: string,
|
|
15
|
+
opts?: { token?: string; baseUrl?: string },
|
|
16
|
+
): Promise<string | null> {
|
|
17
|
+
const token = opts?.token ?? process.env['UGLY_BOT_TOKEN'] ?? '';
|
|
18
|
+
if (!userId || !token) return null;
|
|
19
|
+
const base = opts?.baseUrl ?? getUglyBotUrl();
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`${base}/v1/users/email`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
Authorization: `Bearer ${token}`,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ userId }),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) return null;
|
|
30
|
+
const data = (await res.json()) as { email?: string | null };
|
|
31
|
+
return typeof data.email === 'string' && data.email.trim() ? data.email.trim() : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -213,6 +213,24 @@ export const frameworkRequests = defineRequests({
|
|
|
213
213
|
}),
|
|
214
214
|
}),
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Admin-only: mark a feedback report resolved/declined and notify the reporter.
|
|
218
|
+
* Updates the `feedback_report` D1 row (status + resolution + resolved_at), then
|
|
219
|
+
* looks up the reporter's email by userId via ugly.bot and sends the notice from
|
|
220
|
+
* THIS app's own domain (email is best-effort — `emailed` reflects whether it
|
|
221
|
+
* sent). Called by the `feedback:resolve` CLI with the owner's token; gated by
|
|
222
|
+
* `resolvedIsAdmin` (MAINTAIN_BOT_USER_ID or the ADMIN_USER_IDS allowlist).
|
|
223
|
+
*/
|
|
224
|
+
feedbackReportResolve: authReq({
|
|
225
|
+
input: z.object({
|
|
226
|
+
id: z.string().min(1),
|
|
227
|
+
status: z.enum(["resolved", "declined"]),
|
|
228
|
+
resolution: z.string().max(5000),
|
|
229
|
+
}),
|
|
230
|
+
output: z.object({ ok: z.boolean(), emailed: z.boolean() }),
|
|
231
|
+
rateLimit: { max: 120, window: 60 },
|
|
232
|
+
}),
|
|
233
|
+
|
|
216
234
|
/**
|
|
217
235
|
* Bot-submitted feedback. Wraps `recordFeedback` (which goes through
|
|
218
236
|
* `db.captureFeedback`) so the bot-swarm skill's feedback bots can
|