@kolibri-day/feedback 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -2
  3. package/dist/cjs/adapters/capture/capture.d.ts +112 -0
  4. package/dist/cjs/adapters/capture/capture.js +398 -0
  5. package/dist/cjs/adapters/capture/client-context.d.ts +9 -0
  6. package/dist/cjs/adapters/capture/client-context.js +41 -0
  7. package/dist/cjs/adapters/capture/reporter.d.ts +42 -0
  8. package/dist/cjs/adapters/capture/reporter.js +83 -0
  9. package/dist/cjs/adapters/capture/submit.d.ts +30 -0
  10. package/dist/cjs/adapters/capture/submit.js +32 -0
  11. package/dist/cjs/adapters/kolibri/env.d.ts +16 -0
  12. package/dist/cjs/adapters/kolibri/env.js +40 -0
  13. package/dist/cjs/adapters/kolibri/preflight.d.ts +6 -0
  14. package/dist/cjs/adapters/kolibri/preflight.js +536 -0
  15. package/dist/cjs/adapters/kolibri/target.d.ts +100 -0
  16. package/dist/cjs/adapters/kolibri/target.js +175 -0
  17. package/dist/cjs/adapters/react/reporter.d.ts +25 -0
  18. package/dist/cjs/adapters/react/reporter.js +54 -0
  19. package/dist/cjs/browser.d.ts +4 -0
  20. package/dist/cjs/browser.js +22 -0
  21. package/dist/cjs/index.d.ts +6 -0
  22. package/dist/cjs/index.js +25 -0
  23. package/dist/cjs/kernel/feedback/dispatch.d.ts +64 -0
  24. package/dist/cjs/kernel/feedback/dispatch.js +93 -0
  25. package/dist/cjs/kernel/feedback/fake-target.d.ts +58 -0
  26. package/dist/cjs/kernel/feedback/fake-target.js +87 -0
  27. package/dist/cjs/kernel/feedback/files.d.ts +6 -0
  28. package/dist/cjs/kernel/feedback/files.js +21 -0
  29. package/dist/cjs/kernel/feedback/format.d.ts +53 -0
  30. package/dist/cjs/kernel/feedback/format.js +140 -0
  31. package/dist/cjs/kernel/feedback/input.d.ts +79 -0
  32. package/dist/cjs/kernel/feedback/input.js +157 -0
  33. package/dist/cjs/kernel/feedback/messages.d.ts +76 -0
  34. package/dist/cjs/kernel/feedback/messages.js +138 -0
  35. package/dist/cjs/kernel/feedback/notice.d.ts +10 -0
  36. package/dist/cjs/kernel/feedback/notice.js +19 -0
  37. package/dist/cjs/kernel/feedback/types.d.ts +158 -0
  38. package/dist/cjs/kernel/feedback/types.js +47 -0
  39. package/dist/cjs/kolibri.d.ts +2 -0
  40. package/dist/cjs/kolibri.js +12 -0
  41. package/dist/cjs/package.json +1 -0
  42. package/dist/cjs/preflight-cli.d.ts +2 -0
  43. package/dist/cjs/preflight-cli.js +10 -0
  44. package/dist/cjs/react.d.ts +3 -0
  45. package/dist/cjs/react.js +9 -0
  46. package/dist/cjs/server.d.ts +4 -0
  47. package/dist/cjs/server.js +18 -0
  48. package/dist/cjs/wiring.d.ts +38 -0
  49. package/dist/cjs/wiring.js +72 -0
  50. package/dist/esm/adapters/capture/capture.d.ts +112 -0
  51. package/dist/esm/adapters/capture/capture.js +357 -0
  52. package/dist/esm/adapters/capture/client-context.d.ts +9 -0
  53. package/dist/esm/adapters/capture/client-context.js +38 -0
  54. package/dist/esm/adapters/capture/reporter.d.ts +42 -0
  55. package/dist/esm/adapters/capture/reporter.js +79 -0
  56. package/dist/esm/adapters/capture/submit.d.ts +30 -0
  57. package/dist/esm/adapters/capture/submit.js +28 -0
  58. package/dist/esm/adapters/kolibri/env.d.ts +16 -0
  59. package/dist/esm/adapters/kolibri/env.js +36 -0
  60. package/dist/esm/adapters/kolibri/preflight.d.ts +6 -0
  61. package/dist/esm/adapters/kolibri/preflight.js +533 -0
  62. package/dist/esm/adapters/kolibri/target.d.ts +100 -0
  63. package/dist/esm/adapters/kolibri/target.js +170 -0
  64. package/dist/esm/adapters/react/reporter.d.ts +25 -0
  65. package/dist/esm/adapters/react/reporter.js +49 -0
  66. package/dist/esm/browser.d.ts +4 -0
  67. package/dist/esm/browser.js +7 -0
  68. package/dist/esm/index.d.ts +6 -0
  69. package/dist/esm/index.js +9 -0
  70. package/dist/esm/kernel/feedback/dispatch.d.ts +64 -0
  71. package/dist/esm/kernel/feedback/dispatch.js +89 -0
  72. package/dist/esm/kernel/feedback/fake-target.d.ts +58 -0
  73. package/dist/esm/kernel/feedback/fake-target.js +82 -0
  74. package/dist/esm/kernel/feedback/files.d.ts +6 -0
  75. package/dist/esm/kernel/feedback/files.js +16 -0
  76. package/dist/esm/kernel/feedback/format.d.ts +53 -0
  77. package/dist/esm/kernel/feedback/format.js +133 -0
  78. package/dist/esm/kernel/feedback/input.d.ts +79 -0
  79. package/dist/esm/kernel/feedback/input.js +150 -0
  80. package/dist/esm/kernel/feedback/messages.d.ts +76 -0
  81. package/dist/esm/kernel/feedback/messages.js +135 -0
  82. package/dist/esm/kernel/feedback/notice.d.ts +10 -0
  83. package/dist/esm/kernel/feedback/notice.js +15 -0
  84. package/dist/esm/kernel/feedback/types.d.ts +158 -0
  85. package/dist/esm/kernel/feedback/types.js +42 -0
  86. package/dist/esm/kolibri.d.ts +2 -0
  87. package/dist/esm/kolibri.js +3 -0
  88. package/dist/esm/preflight-cli.d.ts +2 -0
  89. package/dist/esm/preflight-cli.js +8 -0
  90. package/dist/esm/react.d.ts +3 -0
  91. package/dist/esm/react.js +3 -0
  92. package/dist/esm/server.d.ts +4 -0
  93. package/dist/esm/server.js +6 -0
  94. package/dist/esm/wiring.d.ts +38 -0
  95. package/dist/esm/wiring.js +68 -0
  96. package/fixtures/preflight-screenshot.webp +0 -0
  97. package/package.json +82 -4
@@ -0,0 +1,82 @@
1
+ import { FeedbackTargetError } from "./types.js";
2
+ import { en } from "./messages.js";
3
+ export class FakeFeedbackTarget {
4
+ name = 'fake';
5
+ label;
6
+ /** Nothing leaves the process, so it is trivially inside the trust boundary. */
7
+ trustBoundary = 'self_hosted';
8
+ /** Every submit() call, in order — including the ones that then threw. */
9
+ submissions = [];
10
+ /** Calls that actually created a new item (i.e. were not resumes). Duplicate-ticket detector. */
11
+ creates = [];
12
+ seq = 0;
13
+ failures = [];
14
+ webUrl;
15
+ constructor(options = {}) {
16
+ this.webUrl = options.webUrl ?? 'https://fake.invalid';
17
+ this.label = options.label ?? en.targets.fake;
18
+ }
19
+ /** Queue a failure for the NEXT submit() call (repeat to script a sequence). */
20
+ failNext(error) {
21
+ this.failures.push(error);
22
+ return this;
23
+ }
24
+ /** Convenience: the two classifications, scripted. */
25
+ failNextTransient(message = 'target unreachable') {
26
+ return this.failNext(new FeedbackTargetError(message, false));
27
+ }
28
+ failNextPermanent(message = 'target rejected the report') {
29
+ return this.failNext(new FeedbackTargetError(message, true, 422));
30
+ }
31
+ /**
32
+ * Script the partial failure that resumability exists for: the item was created,
33
+ * the attachment upload then failed. The error carries the id, exactly as a real
34
+ * target does, so a durable caller can persist it and resume.
35
+ */
36
+ failNextAfterCreate(message = 'attachment upload failed') {
37
+ const id = `fake_${++this.seq}`;
38
+ this.creates.push(id);
39
+ return this.failNext(new FeedbackTargetError(message, false, 502, id, `${this.webUrl}/tasks/${id}`));
40
+ }
41
+ async submit(report, resume) {
42
+ const scripted = this.failures.shift();
43
+ if (scripted) {
44
+ this.submissions.push({ report, resume, externalId: scripted.externalId ?? '' });
45
+ throw scripted;
46
+ }
47
+ const externalId = resume?.externalId ?? `fake_${++this.seq}`;
48
+ if (!resume)
49
+ this.creates.push(externalId);
50
+ this.submissions.push({ report, resume, externalId });
51
+ return {
52
+ externalId,
53
+ externalUrl: `${this.webUrl}/tasks/${externalId}`,
54
+ attachmentsUploaded: report.attachments.length,
55
+ };
56
+ }
57
+ }
58
+ /**
59
+ * A target that always fails permanently with a configuration message.
60
+ *
61
+ * This is what an unconfigured production deployment gets, and the choice is
62
+ * deliberate: reports are still PERSISTED and the failure is LOUD (a failed report
63
+ * naming the missing variables). The alternative — silently discarding reports, or
64
+ * refusing the button — would mean people file feedback into nothing and conclude the
65
+ * team ignored them.
66
+ *
67
+ * It is also why a missing variable does not throw at process BOOT: that would take
68
+ * every other job in the same worker down over a feedback misconfiguration.
69
+ */
70
+ export class UnconfiguredFeedbackTarget {
71
+ name = 'unconfigured';
72
+ trustBoundary = 'self_hosted';
73
+ label;
74
+ reason;
75
+ constructor(reason, label = en.targets.unconfigured) {
76
+ this.reason = reason;
77
+ this.label = label;
78
+ }
79
+ async submit(_report, _resume) {
80
+ throw new FeedbackTargetError(this.reason, true);
81
+ }
82
+ }
@@ -0,0 +1,6 @@
1
+ /** The image types the capture produces and the server accepts. */
2
+ export declare const SCREENSHOT_MIME_TYPES: readonly ["image/webp", "image/jpeg", "image/png"];
3
+ /** File extension matching a produced MIME. PNG is the fallback — it is what a canvas emits when asked for a type it cannot encode. */
4
+ export declare function extensionFor(mime: string): string;
5
+ /** "screenshot.webp" etc. */
6
+ export declare function screenshotFilename(mime: string): string;
@@ -0,0 +1,16 @@
1
+ // Screenshot file naming, shared by the browser (which names the upload) and the
2
+ // delivery side (which names the attachment at the target) so the two cannot drift.
3
+ /** The image types the capture produces and the server accepts. */
4
+ export const SCREENSHOT_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/png'];
5
+ /** File extension matching a produced MIME. PNG is the fallback — it is what a canvas emits when asked for a type it cannot encode. */
6
+ export function extensionFor(mime) {
7
+ if (mime === 'image/webp')
8
+ return 'webp';
9
+ if (mime === 'image/jpeg')
10
+ return 'jpg';
11
+ return 'png';
12
+ }
13
+ /** "screenshot.webp" etc. */
14
+ export function screenshotFilename(mime) {
15
+ return `screenshot.${extensionFor(mime)}`;
16
+ }
@@ -0,0 +1,53 @@
1
+ import type { FeedbackReport } from './types.js';
2
+ import { type FeedbackMessages } from './messages.js';
3
+ export interface FormatOptions {
4
+ /** Language of the labels. Default: German. */
5
+ messages?: FeedbackMessages;
6
+ /** Display labels for the app's own kinds; falls back to `messages.kinds`, then the raw kind. */
7
+ kindLabels?: Readonly<Record<string, string>>;
8
+ }
9
+ export declare function kindLabel(kind: string, options?: FormatOptions): string;
10
+ /** The route part of a reported URL ("/companies/abc?x=1"), or "" if unparseable. */
11
+ export declare function routeOf(url: string | undefined): string;
12
+ /**
13
+ * The task title: kind, route, and the first line of the message.
14
+ *
15
+ * Front-loaded with the things that make a list of tickets scannable — someone
16
+ * triaging sees "what kind, where, what" without opening anything. The reporter's
17
+ * name is deliberately NOT here (it is in the body): a title is a summary of the
18
+ * problem, and a board of "Bug — Max Mustermann" reads as a list of people.
19
+ */
20
+ export declare function buildTitle(report: FeedbackReport, options?: FormatOptions): string;
21
+ /**
22
+ * A fence long enough to survive the text it wraps.
23
+ *
24
+ * CommonMark's rule: a fenced block ends at a run of backticks at least as long as
25
+ * the opening one, so an opener longer than anything inside cannot be closed early.
26
+ * A reporter pasting a fenced code snippet into the feedback box is the case this
27
+ * exists for — without it their ``` closes ours and the rest of the report renders as
28
+ * markup.
29
+ *
30
+ * Kolibri's renderer is explicitly NOT CommonMark-conformant, so this rule was checked
31
+ * against it rather than taken from the spec: the fence closes only on "a marker at
32
+ * least as long as the one that opened it" (packages/shared/src/modules/pages/
33
+ * markdown.ts), and a report carrying a fenced snippet was run
34
+ * through that renderer end to end — both context tables intact, the snippet verbatim.
35
+ */
36
+ export declare function fenceFor(text: string): string;
37
+ /**
38
+ * The task description, as Markdown.
39
+ *
40
+ * Structured in three blocks, in this order and for this reason:
41
+ * 1. the reporter's own words, first and unmangled — that is the actual report;
42
+ * 2. SERVER-DERIVED context, which is trustworthy;
43
+ * 3. CLIENT-REPORTED context, explicitly labelled as such.
44
+ *
45
+ * (2) and (3) are separate tables rather than one merged block precisely so nobody
46
+ * reading the ticket has to remember which fields the browser was free to make up.
47
+ *
48
+ * The reporter's text sits in a fenced block rather than as running prose: a bug
49
+ * report is frequently steps on separate lines, and a fence preserves them exactly
50
+ * while also keeping a stray `#` or `**` from restyling the ticket. `text` as the
51
+ * language tag means no highlighter tries to interpret it.
52
+ */
53
+ export declare function buildDescriptionMarkdown(report: FeedbackReport, options?: FormatOptions): string;
@@ -0,0 +1,133 @@
1
+ import { en } from "./messages.js";
2
+ export function kindLabel(kind, options = {}) {
3
+ const messages = options.messages ?? en;
4
+ return options.kindLabels?.[kind] ?? messages.kinds[kind] ?? kind;
5
+ }
6
+ /** Collapse to a single line and cut to `max`, so a title can't carry a newline or run long. */
7
+ function oneLine(value, max) {
8
+ const flat = value.replace(/\s+/g, ' ').trim();
9
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`;
10
+ }
11
+ /** The route part of a reported URL ("/companies/abc?x=1"), or "" if unparseable. */
12
+ export function routeOf(url) {
13
+ if (!url)
14
+ return '';
15
+ try {
16
+ const u = new URL(url);
17
+ return `${u.pathname}${u.search}`;
18
+ }
19
+ catch {
20
+ // A client-supplied string that isn't a URL. Keep it if it already looks like a
21
+ // path (the common case for a relative report), else drop it rather than guess.
22
+ return url.startsWith('/') ? oneLine(url, 120) : '';
23
+ }
24
+ }
25
+ /**
26
+ * The task title: kind, route, and the first line of the message.
27
+ *
28
+ * Front-loaded with the things that make a list of tickets scannable — someone
29
+ * triaging sees "what kind, where, what" without opening anything. The reporter's
30
+ * name is deliberately NOT here (it is in the body): a title is a summary of the
31
+ * problem, and a board of "Bug — Max Mustermann" reads as a list of people.
32
+ */
33
+ export function buildTitle(report, options = {}) {
34
+ const messages = options.messages ?? en;
35
+ const kind = kindLabel(report.kind, options);
36
+ const route = routeOf(typeof report.client?.url === 'string' ? report.client.url : undefined);
37
+ const summary = oneLine(report.message, 80) || messages.report.untitled;
38
+ return oneLine(route ? `[${kind}] ${route} — ${summary}` : `[${kind}] ${summary}`, 200);
39
+ }
40
+ /**
41
+ * A fence long enough to survive the text it wraps.
42
+ *
43
+ * CommonMark's rule: a fenced block ends at a run of backticks at least as long as
44
+ * the opening one, so an opener longer than anything inside cannot be closed early.
45
+ * A reporter pasting a fenced code snippet into the feedback box is the case this
46
+ * exists for — without it their ``` closes ours and the rest of the report renders as
47
+ * markup.
48
+ *
49
+ * Kolibri's renderer is explicitly NOT CommonMark-conformant, so this rule was checked
50
+ * against it rather than taken from the spec: the fence closes only on "a marker at
51
+ * least as long as the one that opened it" (packages/shared/src/modules/pages/
52
+ * markdown.ts), and a report carrying a fenced snippet was run
53
+ * through that renderer end to end — both context tables intact, the snippet verbatim.
54
+ */
55
+ export function fenceFor(text) {
56
+ const longest = [...text.matchAll(/`+/g)].reduce((max, m) => Math.max(max, m[0].length), 0);
57
+ return '`'.repeat(Math.max(3, longest + 1));
58
+ }
59
+ /**
60
+ * One table cell.
61
+ *
62
+ * Two transformations, both structural: a newline would end the table row, and an
63
+ * unescaped `|` would split one cell into two. `\|` is the escape Kolibri's table
64
+ * parser honours (§B9). Nothing here is a security measure — see the file header.
65
+ */
66
+ function cell(value) {
67
+ const v = value === null || value === undefined || value === '' ? '—' : String(value);
68
+ return v.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|') || '—';
69
+ }
70
+ function table(rows, messages) {
71
+ return [
72
+ `| ${messages.report.field} | ${messages.report.value} |`,
73
+ '| --- | --- |',
74
+ ...rows.map(([label, value]) => `| ${cell(label)} | ${cell(value)} |`),
75
+ ].join('\n');
76
+ }
77
+ /**
78
+ * The task description, as Markdown.
79
+ *
80
+ * Structured in three blocks, in this order and for this reason:
81
+ * 1. the reporter's own words, first and unmangled — that is the actual report;
82
+ * 2. SERVER-DERIVED context, which is trustworthy;
83
+ * 3. CLIENT-REPORTED context, explicitly labelled as such.
84
+ *
85
+ * (2) and (3) are separate tables rather than one merged block precisely so nobody
86
+ * reading the ticket has to remember which fields the browser was free to make up.
87
+ *
88
+ * The reporter's text sits in a fenced block rather than as running prose: a bug
89
+ * report is frequently steps on separate lines, and a fence preserves them exactly
90
+ * while also keeping a stray `#` or `**` from restyling the ticket. `text` as the
91
+ * language tag means no highlighter tries to interpret it.
92
+ */
93
+ export function buildDescriptionMarkdown(report, options = {}) {
94
+ const messages = options.messages ?? en;
95
+ const m = messages.report;
96
+ const r = report.reporter;
97
+ const c = report.client ?? {};
98
+ const message = report.message.trim() || m.untitled;
99
+ const fence = fenceFor(message);
100
+ const name = r.name || '—';
101
+ const serverRows = [
102
+ [m.reportedBy, r.role ? `${name} (${r.role})` : name],
103
+ [m.userId, r.id],
104
+ ...(report.serverContext ?? []),
105
+ [m.reportedAtServer, report.createdAt.toISOString()],
106
+ [m.reportId, report.id],
107
+ ];
108
+ const clientRows = [
109
+ [m.url, c.url],
110
+ [m.pageTitle, c.title],
111
+ [m.viewport, c.viewport],
112
+ [m.screen, c.screen],
113
+ [m.devicePixelRatio, c.devicePixelRatio],
114
+ [m.userAgent, c.userAgent],
115
+ [m.language, c.language],
116
+ [m.timezone, c.timezone],
117
+ [m.online, c.online === undefined ? undefined : c.online ? m.yes : m.no],
118
+ [m.connection, c.connection],
119
+ [m.clientTime, c.clientTime],
120
+ ];
121
+ const backlink = report.backlink?.url ? `[${report.backlink.label ?? m.openReport}](${report.backlink.url})` : '';
122
+ return [
123
+ `**${m.description}**`,
124
+ `${fence}text\n${message}\n${fence}`,
125
+ `**${m.serverContext}**`,
126
+ table(serverRows, messages),
127
+ `**${m.clientContext}**`,
128
+ table(clientRows, messages),
129
+ backlink,
130
+ ]
131
+ .filter(Boolean)
132
+ .join('\n\n');
133
+ }
@@ -0,0 +1,79 @@
1
+ import type { FeedbackClientContext } from './types.js';
2
+ import { type FeedbackMessages } from './messages.js';
3
+ export interface FeedbackLimits {
4
+ /** Free text cap. Generous — a good bug report is long — but not unbounded. */
5
+ maxMessageLength: number;
6
+ /**
7
+ * Hard ceiling on an accepted screenshot. The browser capture targets ~1 MB and a
8
+ * typical UI screenshot is far below that, so anything approaching this is either
9
+ * a pathological page or a hand-crafted request.
10
+ */
11
+ maxScreenshotBytes: number;
12
+ /** Room for the text fields on top of the screenshot, for the whole-body pre-check. */
13
+ maxBodyOverheadBytes: number;
14
+ /** Client-context keys are capped in count and size — it is an untrusted free-form blob. */
15
+ maxContextKeys: number;
16
+ maxContextKeyLength: number;
17
+ maxContextValueLength: number;
18
+ /** The serialized client-context field; anything longer is dropped, not parsed. */
19
+ maxContextJsonLength: number;
20
+ }
21
+ export declare const DEFAULT_LIMITS: Readonly<FeedbackLimits>;
22
+ export interface FeedbackScreenshotInput {
23
+ bytes: Uint8Array;
24
+ contentType: string;
25
+ }
26
+ /** The wire shape — deliberately the COMPLETE set of things a client may say. */
27
+ export interface FeedbackInput {
28
+ kind: string;
29
+ message: string;
30
+ clientContext: unknown;
31
+ screenshot?: FeedbackScreenshotInput | null;
32
+ }
33
+ export interface ParsedFeedbackInput<K extends string = string> {
34
+ kind: K;
35
+ message: string;
36
+ clientContext: FeedbackClientContext;
37
+ screenshot: FeedbackScreenshotInput | null;
38
+ }
39
+ /**
40
+ * A report the server will not accept, with the HTTP status to answer it with and a
41
+ * message worded for the reporter.
42
+ */
43
+ export declare class FeedbackInputError extends Error {
44
+ readonly status: 400 | 413;
45
+ constructor(message: string, status: 400 | 413);
46
+ }
47
+ interface InputOptions {
48
+ limits?: Partial<FeedbackLimits>;
49
+ /** Language of the error messages. Default: German. */
50
+ messages?: FeedbackMessages;
51
+ }
52
+ /**
53
+ * Normalize the untrusted client-context blob.
54
+ *
55
+ * Permissive about WHICH keys arrive (the browser facts worth capturing will change,
56
+ * and a strict allowlist would silently drop new ones) but strict about SIZE and
57
+ * DEPTH: scalars only, bounded key count, bounded string length. An unbounded nested
58
+ * object here would be a free write-amplification primitive into whatever stores it.
59
+ */
60
+ export declare function sanitizeClientContext(raw: unknown, limits?: Partial<FeedbackLimits>): FeedbackClientContext;
61
+ export interface ParseOptions<K extends string> extends InputOptions {
62
+ /** The kinds this app accepts. Anything else is refused rather than defaulted. */
63
+ kinds: readonly K[];
64
+ }
65
+ /** Validate + normalize the wire shape. Pure: no I/O, no session, no side effects. */
66
+ export declare function parseFeedbackInput<K extends string>(input: FeedbackInput, options: ParseOptions<K>): ParsedFeedbackInput<K>;
67
+ /**
68
+ * Read the multipart body the browser half sends (fields `kind`, `message`,
69
+ * `clientContext` as JSON, optional file `screenshot`) into the wire shape.
70
+ *
71
+ * Validation of the CONTENT is parseFeedbackInput's job; this only refuses what must
72
+ * not be buffered at all, and tolerates what is merely decorative:
73
+ * - the declared Content-Length is checked before anything is read into memory
74
+ * (absent or lying headers are still caught by the per-part size check);
75
+ * - a malformed or oversized client context becomes `{}` — it must never be the
76
+ * reason a report is lost.
77
+ */
78
+ export declare function readFeedbackForm(request: Request, options?: InputOptions): Promise<FeedbackInput>;
79
+ export {};
@@ -0,0 +1,150 @@
1
+ import { SCREENSHOT_MIME_TYPES } from "./files.js";
2
+ import { en } from "./messages.js";
3
+ export const DEFAULT_LIMITS = Object.freeze({
4
+ maxMessageLength: 5_000,
5
+ maxScreenshotBytes: 3 * 1024 * 1024,
6
+ maxBodyOverheadBytes: 256 * 1024,
7
+ maxContextKeys: 40,
8
+ maxContextKeyLength: 64,
9
+ maxContextValueLength: 2_000,
10
+ maxContextJsonLength: 20_000,
11
+ });
12
+ /**
13
+ * A report the server will not accept, with the HTTP status to answer it with and a
14
+ * message worded for the reporter.
15
+ */
16
+ export class FeedbackInputError extends Error {
17
+ status;
18
+ constructor(message, status) {
19
+ super(message);
20
+ this.name = 'FeedbackInputError';
21
+ this.status = status;
22
+ }
23
+ }
24
+ function limitsOf(options) {
25
+ return { ...DEFAULT_LIMITS, ...options.limits };
26
+ }
27
+ /**
28
+ * Normalize the untrusted client-context blob.
29
+ *
30
+ * Permissive about WHICH keys arrive (the browser facts worth capturing will change,
31
+ * and a strict allowlist would silently drop new ones) but strict about SIZE and
32
+ * DEPTH: scalars only, bounded key count, bounded string length. An unbounded nested
33
+ * object here would be a free write-amplification primitive into whatever stores it.
34
+ */
35
+ export function sanitizeClientContext(raw, limits = {}) {
36
+ const l = { ...DEFAULT_LIMITS, ...limits };
37
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
38
+ return {};
39
+ const out = {};
40
+ let count = 0;
41
+ for (const [key, value] of Object.entries(raw)) {
42
+ if (count >= l.maxContextKeys)
43
+ break;
44
+ if (key.length > l.maxContextKeyLength)
45
+ continue;
46
+ if (typeof value === 'string') {
47
+ out[key] = value.slice(0, l.maxContextValueLength);
48
+ }
49
+ else if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
50
+ out[key] = value;
51
+ }
52
+ else {
53
+ continue; // objects/arrays/functions — not a browser fact worth carrying
54
+ }
55
+ count++;
56
+ }
57
+ return out;
58
+ }
59
+ /** Validate + normalize the wire shape. Pure: no I/O, no session, no side effects. */
60
+ export function parseFeedbackInput(input, options) {
61
+ const messages = options.messages ?? en;
62
+ const limits = limitsOf(options);
63
+ const message = (input.message ?? '').trim();
64
+ if (message.length === 0)
65
+ throw new FeedbackInputError(messages.input.messageRequired, 400);
66
+ if (message.length > limits.maxMessageLength) {
67
+ throw new FeedbackInputError(messages.input.messageTooLong(limits.maxMessageLength), 400);
68
+ }
69
+ // `includes` on the app's own list, not `in` on an object: "constructor" and
70
+ // "toString" must not pass as kinds.
71
+ if (typeof input.kind !== 'string' || !options.kinds.includes(input.kind)) {
72
+ throw new FeedbackInputError(messages.input.unknownKind, 400);
73
+ }
74
+ let screenshot = null;
75
+ if (input.screenshot) {
76
+ const { bytes, contentType } = input.screenshot;
77
+ if (bytes.byteLength === 0) {
78
+ // An empty part means capture failed client-side. Drop it and keep the report:
79
+ // the reporter's text is the point, and failing the whole submission over a
80
+ // screenshot would lose the one part that cannot be re-derived.
81
+ screenshot = null;
82
+ }
83
+ else if (bytes.byteLength > limits.maxScreenshotBytes) {
84
+ throw new FeedbackInputError(messages.input.screenshotTooLarge, 400);
85
+ }
86
+ else if (!SCREENSHOT_MIME_TYPES.includes(contentType)) {
87
+ throw new FeedbackInputError(messages.input.unsupportedImage, 400);
88
+ }
89
+ else {
90
+ screenshot = { bytes, contentType };
91
+ }
92
+ }
93
+ return {
94
+ kind: input.kind,
95
+ message,
96
+ clientContext: sanitizeClientContext(input.clientContext, limits),
97
+ screenshot,
98
+ };
99
+ }
100
+ /**
101
+ * Read the multipart body the browser half sends (fields `kind`, `message`,
102
+ * `clientContext` as JSON, optional file `screenshot`) into the wire shape.
103
+ *
104
+ * Validation of the CONTENT is parseFeedbackInput's job; this only refuses what must
105
+ * not be buffered at all, and tolerates what is merely decorative:
106
+ * - the declared Content-Length is checked before anything is read into memory
107
+ * (absent or lying headers are still caught by the per-part size check);
108
+ * - a malformed or oversized client context becomes `{}` — it must never be the
109
+ * reason a report is lost.
110
+ */
111
+ export async function readFeedbackForm(request, options = {}) {
112
+ const messages = options.messages ?? en;
113
+ const limits = limitsOf(options);
114
+ const declared = Number(request.headers.get('content-length') ?? '0');
115
+ if (Number.isFinite(declared) && declared > limits.maxScreenshotBytes + limits.maxBodyOverheadBytes) {
116
+ throw new FeedbackInputError(messages.input.bodyTooLarge, 413);
117
+ }
118
+ let form;
119
+ try {
120
+ form = await request.formData();
121
+ }
122
+ catch {
123
+ throw new FeedbackInputError(messages.input.invalidRequest, 400);
124
+ }
125
+ let clientContext = {};
126
+ const rawContext = form.get('clientContext');
127
+ if (typeof rawContext === 'string' && rawContext.length > 0 && rawContext.length < limits.maxContextJsonLength) {
128
+ try {
129
+ clientContext = JSON.parse(rawContext);
130
+ }
131
+ catch {
132
+ clientContext = {};
133
+ }
134
+ }
135
+ const file = form.get('screenshot');
136
+ let screenshot = null;
137
+ if (file && typeof file !== 'string') {
138
+ if (file.size > limits.maxScreenshotBytes)
139
+ throw new FeedbackInputError(messages.input.screenshotTooLarge, 413);
140
+ screenshot = { bytes: new Uint8Array(await file.arrayBuffer()), contentType: file.type };
141
+ }
142
+ const kind = form.get('kind');
143
+ const message = form.get('message');
144
+ return {
145
+ kind: typeof kind === 'string' ? kind : '',
146
+ message: typeof message === 'string' ? message : '',
147
+ clientContext,
148
+ screenshot,
149
+ };
150
+ }
@@ -0,0 +1,76 @@
1
+ export interface FeedbackMessages {
2
+ /** BCP 47 tag, informational. */
3
+ locale: string;
4
+ /** Labels for the default kinds (BUG/IDEA/OTHER). An app with its own kinds passes `kindLabels`. */
5
+ kinds: Readonly<Record<string, string>>;
6
+ /** The Markdown report body (target side). */
7
+ report: {
8
+ untitled: string;
9
+ description: string;
10
+ serverContext: string;
11
+ clientContext: string;
12
+ field: string;
13
+ value: string;
14
+ reportedBy: string;
15
+ userId: string;
16
+ reportedAtServer: string;
17
+ reportId: string;
18
+ url: string;
19
+ pageTitle: string;
20
+ viewport: string;
21
+ screen: string;
22
+ devicePixelRatio: string;
23
+ userAgent: string;
24
+ language: string;
25
+ timezone: string;
26
+ online: string;
27
+ connection: string;
28
+ clientTime: string;
29
+ yes: string;
30
+ no: string;
31
+ openReport: string;
32
+ };
33
+ /** The privacy notice next to the screenshot toggle. */
34
+ notice: {
35
+ selfHosted: (targetLabel: string) => string;
36
+ thirdParty: (targetLabel: string) => string;
37
+ };
38
+ /** Display names of the built-in non-tracker targets. */
39
+ targets: {
40
+ fake: string;
41
+ unconfigured: string;
42
+ };
43
+ /** Errors from the Kolibri target. They end up in an operator-visible "last error". */
44
+ kolibri: {
45
+ unreachable: (path: string, detail: string) => string;
46
+ createRejected: (status: number, body: string) => string;
47
+ uploadRejected: (status: number, body: string) => string;
48
+ };
49
+ /** Configuration problems found while resolving the target from the environment. */
50
+ config: {
51
+ targetUnset: string;
52
+ fakeInProduction: string;
53
+ missingVariables: (target: string, names: readonly string[]) => string;
54
+ notUuid: (variable: string, expected: 'workspace' | 'project', value: string) => string;
55
+ unknownTarget: (value: string, known: readonly string[]) => string;
56
+ };
57
+ /** Validation errors for an incoming report (server side, shown to the reporter). */
58
+ input: {
59
+ messageRequired: string;
60
+ messageTooLong: (max: number) => string;
61
+ unknownKind: string;
62
+ screenshotTooLarge: string;
63
+ unsupportedImage: string;
64
+ bodyTooLarge: string;
65
+ invalidRequest: string;
66
+ };
67
+ }
68
+ export declare const de: FeedbackMessages;
69
+ export declare const en: FeedbackMessages;
70
+ export declare const messages: {
71
+ readonly en: FeedbackMessages;
72
+ readonly de: FeedbackMessages;
73
+ };
74
+ export type FeedbackLocale = keyof typeof messages;
75
+ /** The kinds an app gets when it does not define its own. */
76
+ export declare const DEFAULT_FEEDBACK_KINDS: readonly ["BUG", "IDEA", "OTHER"];