@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.
- package/LICENSE +21 -0
- package/README.md +289 -2
- package/dist/cjs/adapters/capture/capture.d.ts +112 -0
- package/dist/cjs/adapters/capture/capture.js +398 -0
- package/dist/cjs/adapters/capture/client-context.d.ts +9 -0
- package/dist/cjs/adapters/capture/client-context.js +41 -0
- package/dist/cjs/adapters/capture/reporter.d.ts +42 -0
- package/dist/cjs/adapters/capture/reporter.js +83 -0
- package/dist/cjs/adapters/capture/submit.d.ts +30 -0
- package/dist/cjs/adapters/capture/submit.js +32 -0
- package/dist/cjs/adapters/kolibri/env.d.ts +16 -0
- package/dist/cjs/adapters/kolibri/env.js +40 -0
- package/dist/cjs/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/cjs/adapters/kolibri/preflight.js +536 -0
- package/dist/cjs/adapters/kolibri/target.d.ts +100 -0
- package/dist/cjs/adapters/kolibri/target.js +175 -0
- package/dist/cjs/adapters/react/reporter.d.ts +25 -0
- package/dist/cjs/adapters/react/reporter.js +54 -0
- package/dist/cjs/browser.d.ts +4 -0
- package/dist/cjs/browser.js +22 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/cjs/kernel/feedback/dispatch.js +93 -0
- package/dist/cjs/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/cjs/kernel/feedback/fake-target.js +87 -0
- package/dist/cjs/kernel/feedback/files.d.ts +6 -0
- package/dist/cjs/kernel/feedback/files.js +21 -0
- package/dist/cjs/kernel/feedback/format.d.ts +53 -0
- package/dist/cjs/kernel/feedback/format.js +140 -0
- package/dist/cjs/kernel/feedback/input.d.ts +79 -0
- package/dist/cjs/kernel/feedback/input.js +157 -0
- package/dist/cjs/kernel/feedback/messages.d.ts +76 -0
- package/dist/cjs/kernel/feedback/messages.js +138 -0
- package/dist/cjs/kernel/feedback/notice.d.ts +10 -0
- package/dist/cjs/kernel/feedback/notice.js +19 -0
- package/dist/cjs/kernel/feedback/types.d.ts +158 -0
- package/dist/cjs/kernel/feedback/types.js +47 -0
- package/dist/cjs/kolibri.d.ts +2 -0
- package/dist/cjs/kolibri.js +12 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/preflight-cli.d.ts +2 -0
- package/dist/cjs/preflight-cli.js +10 -0
- package/dist/cjs/react.d.ts +3 -0
- package/dist/cjs/react.js +9 -0
- package/dist/cjs/server.d.ts +4 -0
- package/dist/cjs/server.js +18 -0
- package/dist/cjs/wiring.d.ts +38 -0
- package/dist/cjs/wiring.js +72 -0
- package/dist/esm/adapters/capture/capture.d.ts +112 -0
- package/dist/esm/adapters/capture/capture.js +357 -0
- package/dist/esm/adapters/capture/client-context.d.ts +9 -0
- package/dist/esm/adapters/capture/client-context.js +38 -0
- package/dist/esm/adapters/capture/reporter.d.ts +42 -0
- package/dist/esm/adapters/capture/reporter.js +79 -0
- package/dist/esm/adapters/capture/submit.d.ts +30 -0
- package/dist/esm/adapters/capture/submit.js +28 -0
- package/dist/esm/adapters/kolibri/env.d.ts +16 -0
- package/dist/esm/adapters/kolibri/env.js +36 -0
- package/dist/esm/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/esm/adapters/kolibri/preflight.js +533 -0
- package/dist/esm/adapters/kolibri/target.d.ts +100 -0
- package/dist/esm/adapters/kolibri/target.js +170 -0
- package/dist/esm/adapters/react/reporter.d.ts +25 -0
- package/dist/esm/adapters/react/reporter.js +49 -0
- package/dist/esm/browser.d.ts +4 -0
- package/dist/esm/browser.js +7 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/esm/kernel/feedback/dispatch.js +89 -0
- package/dist/esm/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/esm/kernel/feedback/fake-target.js +82 -0
- package/dist/esm/kernel/feedback/files.d.ts +6 -0
- package/dist/esm/kernel/feedback/files.js +16 -0
- package/dist/esm/kernel/feedback/format.d.ts +53 -0
- package/dist/esm/kernel/feedback/format.js +133 -0
- package/dist/esm/kernel/feedback/input.d.ts +79 -0
- package/dist/esm/kernel/feedback/input.js +150 -0
- package/dist/esm/kernel/feedback/messages.d.ts +76 -0
- package/dist/esm/kernel/feedback/messages.js +135 -0
- package/dist/esm/kernel/feedback/notice.d.ts +10 -0
- package/dist/esm/kernel/feedback/notice.js +15 -0
- package/dist/esm/kernel/feedback/types.d.ts +158 -0
- package/dist/esm/kernel/feedback/types.js +42 -0
- package/dist/esm/kolibri.d.ts +2 -0
- package/dist/esm/kolibri.js +3 -0
- package/dist/esm/preflight-cli.d.ts +2 -0
- package/dist/esm/preflight-cli.js +8 -0
- package/dist/esm/react.d.ts +3 -0
- package/dist/esm/react.js +3 -0
- package/dist/esm/server.d.ts +4 -0
- package/dist/esm/server.js +6 -0
- package/dist/esm/wiring.d.ts +38 -0
- package/dist/esm/wiring.js +68 -0
- package/fixtures/preflight-screenshot.webp +0 -0
- package/package.json +82 -4
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Every user- and operator-facing string the package produces, per language.
|
|
2
|
+
//
|
|
3
|
+
// English is the default; German ships alongside, because the first app to use this
|
|
4
|
+
// speaks it. An app that needs different wording overrides single entries by
|
|
5
|
+
// spreading:
|
|
6
|
+
//
|
|
7
|
+
// const messages = { ...en, notice: { ...en.notice, selfHosted: (l) => `…${l}…` } };
|
|
8
|
+
//
|
|
9
|
+
// Parameterised strings are functions rather than templates with placeholders: the
|
|
10
|
+
// compiler then checks every call site passes what the sentence needs.
|
|
11
|
+
export const de = {
|
|
12
|
+
locale: 'de',
|
|
13
|
+
kinds: { BUG: 'Bug', IDEA: 'Idee', OTHER: 'Sonstiges' },
|
|
14
|
+
report: {
|
|
15
|
+
untitled: '(ohne Beschreibung)',
|
|
16
|
+
description: 'Beschreibung',
|
|
17
|
+
serverContext: 'Kontext (serverseitig ermittelt)',
|
|
18
|
+
clientContext: 'Kontext (clientseitig gemeldet — nicht verifiziert)',
|
|
19
|
+
field: 'Feld',
|
|
20
|
+
value: 'Wert',
|
|
21
|
+
reportedBy: 'Gemeldet von',
|
|
22
|
+
userId: 'Nutzer-ID',
|
|
23
|
+
reportedAtServer: 'Gemeldet am (Server)',
|
|
24
|
+
reportId: 'Report-ID',
|
|
25
|
+
url: 'URL',
|
|
26
|
+
pageTitle: 'Seitentitel',
|
|
27
|
+
viewport: 'Viewport',
|
|
28
|
+
screen: 'Bildschirm',
|
|
29
|
+
devicePixelRatio: 'Device Pixel Ratio',
|
|
30
|
+
userAgent: 'Browser (User-Agent)',
|
|
31
|
+
language: 'Sprache',
|
|
32
|
+
timezone: 'Zeitzone',
|
|
33
|
+
online: 'Online',
|
|
34
|
+
connection: 'Verbindung',
|
|
35
|
+
clientTime: 'Uhrzeit (Client)',
|
|
36
|
+
yes: 'ja',
|
|
37
|
+
no: 'nein',
|
|
38
|
+
openReport: 'Report in der App öffnen',
|
|
39
|
+
},
|
|
40
|
+
notice: {
|
|
41
|
+
selfHosted: (label) => `Der Screenshot zeigt diese Ansicht inkl. aller darin sichtbaren Daten und wird in eurem selbst-betriebenen ${label} gespeichert.`,
|
|
42
|
+
thirdParty: (label) => `Achtung: Der Screenshot zeigt diese Ansicht inkl. aller darin sichtbaren Daten und wird an ${label} übertragen — ein externes System außerhalb eurer eigenen Infrastruktur.`,
|
|
43
|
+
},
|
|
44
|
+
targets: {
|
|
45
|
+
fake: 'Test-Ziel (lokal)',
|
|
46
|
+
unconfigured: 'kein Zielsystem',
|
|
47
|
+
},
|
|
48
|
+
kolibri: {
|
|
49
|
+
unreachable: (path, detail) => `Kolibri nicht erreichbar (${path}): ${detail}`,
|
|
50
|
+
createRejected: (status, body) => `Kolibri hat die Task-Erstellung abgelehnt (HTTP ${status}): ${body}`,
|
|
51
|
+
uploadRejected: (status, body) => `Kolibri hat den Datei-Upload abgelehnt (HTTP ${status}): ${body}`,
|
|
52
|
+
},
|
|
53
|
+
config: {
|
|
54
|
+
targetUnset: 'FEEDBACK_TARGET ist nicht gesetzt — Reports werden gespeichert, aber an kein Zielsystem übermittelt.',
|
|
55
|
+
fakeInProduction: 'FEEDBACK_TARGET=fake in production — Reports gehen an kein echtes Zielsystem.',
|
|
56
|
+
missingVariables: (target, names) => `FEEDBACK_TARGET=${target}, aber folgende Variablen fehlen: ${names.join(', ')}.`,
|
|
57
|
+
notUuid: (variable, expected, value) => expected === 'workspace'
|
|
58
|
+
? `${variable} muss eine UUID sein, kein Slug (ist: "${value}").`
|
|
59
|
+
: `${variable} muss eine UUID sein, kein Projekt-Key (ist: "${value}").`,
|
|
60
|
+
unknownTarget: (value, known) => `FEEDBACK_TARGET="${value}" ist unbekannt (erwartet: ${known.map((k) => `"${k}"`).join(' oder ')}).`,
|
|
61
|
+
},
|
|
62
|
+
input: {
|
|
63
|
+
messageRequired: 'Bitte beschreiben Sie kurz, worum es geht.',
|
|
64
|
+
messageTooLong: (max) => `Die Beschreibung ist zu lang (max. ${max} Zeichen).`,
|
|
65
|
+
unknownKind: 'Unbekannte Art der Rückmeldung.',
|
|
66
|
+
screenshotTooLarge: 'Der Screenshot ist zu groß.',
|
|
67
|
+
unsupportedImage: 'Nicht unterstütztes Bildformat.',
|
|
68
|
+
bodyTooLarge: 'Die Rückmeldung ist zu groß.',
|
|
69
|
+
invalidRequest: 'Ungültige Anfrage.',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
export const en = {
|
|
73
|
+
locale: 'en',
|
|
74
|
+
kinds: { BUG: 'Bug', IDEA: 'Idea', OTHER: 'Other' },
|
|
75
|
+
report: {
|
|
76
|
+
untitled: '(no description)',
|
|
77
|
+
description: 'Description',
|
|
78
|
+
serverContext: 'Context (determined server-side)',
|
|
79
|
+
clientContext: 'Context (reported by the client — not verified)',
|
|
80
|
+
field: 'Field',
|
|
81
|
+
value: 'Value',
|
|
82
|
+
reportedBy: 'Reported by',
|
|
83
|
+
userId: 'User ID',
|
|
84
|
+
reportedAtServer: 'Reported at (server)',
|
|
85
|
+
reportId: 'Report ID',
|
|
86
|
+
url: 'URL',
|
|
87
|
+
pageTitle: 'Page title',
|
|
88
|
+
viewport: 'Viewport',
|
|
89
|
+
screen: 'Screen',
|
|
90
|
+
devicePixelRatio: 'Device pixel ratio',
|
|
91
|
+
userAgent: 'Browser (user agent)',
|
|
92
|
+
language: 'Language',
|
|
93
|
+
timezone: 'Time zone',
|
|
94
|
+
online: 'Online',
|
|
95
|
+
connection: 'Connection',
|
|
96
|
+
clientTime: 'Time (client)',
|
|
97
|
+
yes: 'yes',
|
|
98
|
+
no: 'no',
|
|
99
|
+
openReport: 'Open report in the app',
|
|
100
|
+
},
|
|
101
|
+
notice: {
|
|
102
|
+
selfHosted: (label) => `The screenshot shows this view, including any data visible in it, and is stored in your self-hosted ${label}.`,
|
|
103
|
+
thirdParty: (label) => `Attention: the screenshot shows this view, including any data visible in it, and is sent to ${label} — an external system outside your own infrastructure.`,
|
|
104
|
+
},
|
|
105
|
+
targets: {
|
|
106
|
+
fake: 'Test target (local)',
|
|
107
|
+
unconfigured: 'no target system',
|
|
108
|
+
},
|
|
109
|
+
kolibri: {
|
|
110
|
+
unreachable: (path, detail) => `Kolibri unreachable (${path}): ${detail}`,
|
|
111
|
+
createRejected: (status, body) => `Kolibri rejected the task creation (HTTP ${status}): ${body}`,
|
|
112
|
+
uploadRejected: (status, body) => `Kolibri rejected the file upload (HTTP ${status}): ${body}`,
|
|
113
|
+
},
|
|
114
|
+
config: {
|
|
115
|
+
targetUnset: 'FEEDBACK_TARGET is not set — reports are stored but delivered to no target system.',
|
|
116
|
+
fakeInProduction: 'FEEDBACK_TARGET=fake in production — reports reach no real target system.',
|
|
117
|
+
missingVariables: (target, names) => `FEEDBACK_TARGET=${target}, but these variables are missing: ${names.join(', ')}.`,
|
|
118
|
+
notUuid: (variable, expected, value) => expected === 'workspace'
|
|
119
|
+
? `${variable} must be a UUID, not a slug (is: "${value}").`
|
|
120
|
+
: `${variable} must be a UUID, not a project key (is: "${value}").`,
|
|
121
|
+
unknownTarget: (value, known) => `FEEDBACK_TARGET="${value}" is unknown (expected: ${known.map((k) => `"${k}"`).join(' or ')}).`,
|
|
122
|
+
},
|
|
123
|
+
input: {
|
|
124
|
+
messageRequired: 'Please describe briefly what this is about.',
|
|
125
|
+
messageTooLong: (max) => `The description is too long (max. ${max} characters).`,
|
|
126
|
+
unknownKind: 'Unknown kind of feedback.',
|
|
127
|
+
screenshotTooLarge: 'The screenshot is too large.',
|
|
128
|
+
unsupportedImage: 'Unsupported image format.',
|
|
129
|
+
bodyTooLarge: 'The feedback is too large.',
|
|
130
|
+
invalidRequest: 'Invalid request.',
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
export const messages = { en, de };
|
|
134
|
+
/** The kinds an app gets when it does not define its own. */
|
|
135
|
+
export const DEFAULT_FEEDBACK_KINDS = ['BUG', 'IDEA', 'OTHER'];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FeedbackTargetDescriptor } from './types.js';
|
|
2
|
+
import { type FeedbackMessages } from './messages.js';
|
|
3
|
+
/** Is the screenshot attached by default? Only inside our own trust boundary. */
|
|
4
|
+
export declare function screenshotDefaultOn(target: Pick<FeedbackTargetDescriptor, 'trustBoundary'>): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* The notice text. Names what the screenshot contains AND where it goes — a warning
|
|
7
|
+
* that says only "a screenshot will be attached" tells the reporter nothing they
|
|
8
|
+
* could act on.
|
|
9
|
+
*/
|
|
10
|
+
export declare function screenshotNotice(target: FeedbackTargetDescriptor, messages?: FeedbackMessages): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { en } from "./messages.js";
|
|
2
|
+
/** Is the screenshot attached by default? Only inside our own trust boundary. */
|
|
3
|
+
export function screenshotDefaultOn(target) {
|
|
4
|
+
return target.trustBoundary === 'self_hosted';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The notice text. Names what the screenshot contains AND where it goes — a warning
|
|
8
|
+
* that says only "a screenshot will be attached" tells the reporter nothing they
|
|
9
|
+
* could act on.
|
|
10
|
+
*/
|
|
11
|
+
export function screenshotNotice(target, messages = en) {
|
|
12
|
+
return target.trustBoundary === 'self_hosted'
|
|
13
|
+
? messages.notice.selfHosted(target.label)
|
|
14
|
+
: messages.notice.thirdParty(target.label);
|
|
15
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the configured target is run by the same organisation as the app, or by
|
|
3
|
+
* someone else.
|
|
4
|
+
*
|
|
5
|
+
* This is the ONE property of a target that deliberately leaks upward into the UI,
|
|
6
|
+
* and it earns that: it drives the PRIVACY POSTURE, not the transport. A screenshot
|
|
7
|
+
* of an application view contains whatever data that view shows, so what the
|
|
8
|
+
* reporter is told before they attach it — and whether attaching is the default at
|
|
9
|
+
* all — depends on where those bytes are about to land.
|
|
10
|
+
*
|
|
11
|
+
* Self-hosted means the same data controller, no new processor, no third-country
|
|
12
|
+
* transfer: an internal data flow, not a disclosure. A third-party target is a
|
|
13
|
+
* genuinely different question (GDPR Art. 28 processor agreement), and putting the
|
|
14
|
+
* distinction in the TYPE is what forces whoever adds such a target to answer it
|
|
15
|
+
* rather than inherit an answer that was never about them.
|
|
16
|
+
*/
|
|
17
|
+
export type TrustBoundary = 'self_hosted' | 'third_party';
|
|
18
|
+
/**
|
|
19
|
+
* What a target IS, independent of whether its credentials are present — the
|
|
20
|
+
* safe-to-serialize view a web tier hands to the browser so the privacy notice can
|
|
21
|
+
* name where the screenshot is going. Three strings, none of them secret.
|
|
22
|
+
*/
|
|
23
|
+
export interface FeedbackTargetDescriptor {
|
|
24
|
+
/** Stable id, persisted on the report ("kolibri"). */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Display name for the notice ("Kolibri"). */
|
|
27
|
+
label: string;
|
|
28
|
+
trustBoundary: TrustBoundary;
|
|
29
|
+
}
|
|
30
|
+
/** A file to attach to the created item. In practice: one screenshot, sometimes none. */
|
|
31
|
+
export interface FeedbackAttachment {
|
|
32
|
+
filename: string;
|
|
33
|
+
contentType: string;
|
|
34
|
+
bytes: Uint8Array;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Who filed the report — resolved SERVER-SIDE from the app's own session, never from
|
|
38
|
+
* the request body. The client posts a message, a kind, a screenshot and a bag of
|
|
39
|
+
* browser facts; who it claims to be is not among the things it gets to say.
|
|
40
|
+
*/
|
|
41
|
+
export interface FeedbackReporter {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
/** Optional role, shown next to the name ("Max Mustermann (ADMIN)"). */
|
|
45
|
+
role?: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** A scalar fact in one of the report's context tables. `null`/`undefined` render as "—". */
|
|
48
|
+
export type FeedbackFactValue = string | number | boolean | null | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Further SERVER-DERIVED facts for the trusted context table, as `[label, value]`
|
|
51
|
+
* pairs in display order (tenant, active workspace, feature flags …). A tuple list
|
|
52
|
+
* rather than a record so the order is exactly what the app wrote — a record would
|
|
53
|
+
* silently hoist integer-like labels to the front.
|
|
54
|
+
*
|
|
55
|
+
* A `null` value still renders its row: "no active workspace" is information, and an
|
|
56
|
+
* omitted row reads as a forgotten field.
|
|
57
|
+
*/
|
|
58
|
+
export type FeedbackServerContext = ReadonlyArray<readonly [label: string, value: FeedbackFactValue]>;
|
|
59
|
+
/**
|
|
60
|
+
* Browser facts as REPORTED BY THE CLIENT. Every consumer must present these as
|
|
61
|
+
* client-reported rather than mixing them in with the server-derived context: a
|
|
62
|
+
* debugging artifact that blurs verified and self-reported facts is how people end up
|
|
63
|
+
* chasing ghosts. Loosely typed on purpose — a target formats whatever it is given and
|
|
64
|
+
* never depends on a particular key.
|
|
65
|
+
*/
|
|
66
|
+
export interface FeedbackClientContext {
|
|
67
|
+
url?: string;
|
|
68
|
+
title?: string;
|
|
69
|
+
viewport?: string;
|
|
70
|
+
screen?: string;
|
|
71
|
+
devicePixelRatio?: number;
|
|
72
|
+
userAgent?: string;
|
|
73
|
+
language?: string;
|
|
74
|
+
timezone?: string;
|
|
75
|
+
online?: boolean;
|
|
76
|
+
connection?: string | null;
|
|
77
|
+
clientTime?: string;
|
|
78
|
+
[key: string]: unknown;
|
|
79
|
+
}
|
|
80
|
+
/** A link from the ticket back to the report in the reporting app. */
|
|
81
|
+
export interface FeedbackBacklink {
|
|
82
|
+
url: string;
|
|
83
|
+
/** Link text; defaults to the messages' `report.openReport`. */
|
|
84
|
+
label?: string;
|
|
85
|
+
}
|
|
86
|
+
/** The normalized report — the ONLY thing a FeedbackTarget consumes. */
|
|
87
|
+
export interface FeedbackReport<K extends string = string> {
|
|
88
|
+
/**
|
|
89
|
+
* The app's own report id. Backlink, support reference — and for Kolibri the TASK
|
|
90
|
+
* id, which is what makes delivery idempotent. Must be a UUID for that target.
|
|
91
|
+
*/
|
|
92
|
+
id: string;
|
|
93
|
+
kind: K;
|
|
94
|
+
message: string;
|
|
95
|
+
reporter: FeedbackReporter;
|
|
96
|
+
serverContext?: FeedbackServerContext;
|
|
97
|
+
client: FeedbackClientContext;
|
|
98
|
+
attachments: FeedbackAttachment[];
|
|
99
|
+
/** When the app received the report (server clock). */
|
|
100
|
+
createdAt: Date;
|
|
101
|
+
backlink?: FeedbackBacklink | null;
|
|
102
|
+
}
|
|
103
|
+
/** What the target created. `externalUrl` is the human backlink, when the target has one. */
|
|
104
|
+
export interface FeedbackSubmission {
|
|
105
|
+
externalId: string;
|
|
106
|
+
externalUrl: string | null;
|
|
107
|
+
/** May be FEWER than the report's attachments — a partial upload is a real outcome. */
|
|
108
|
+
attachmentsUploaded: number;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A target failure, classified.
|
|
112
|
+
*
|
|
113
|
+
* `permanent` is what stops a retry policy burning its whole attempt budget on a
|
|
114
|
+
* misconfigured project id, and equally what stops a transient 502 being written off
|
|
115
|
+
* as a dead report. The rule: a 4xx that no amount of retrying will fix (auth,
|
|
116
|
+
* not-found, unprocessable) is permanent; 5xx, network and timeout are not.
|
|
117
|
+
*
|
|
118
|
+
* `externalId` is set when the failure happened AFTER the item was created (i.e. the
|
|
119
|
+
* attachment upload failed). A durable caller persists it so the retry resumes the
|
|
120
|
+
* upload instead of creating a second ticket.
|
|
121
|
+
*/
|
|
122
|
+
export declare class FeedbackTargetError extends Error {
|
|
123
|
+
readonly permanent: boolean;
|
|
124
|
+
readonly status?: number;
|
|
125
|
+
readonly externalId?: string;
|
|
126
|
+
readonly externalUrl?: string | null;
|
|
127
|
+
constructor(message: string, permanent: boolean, status?: number, externalId?: string, externalUrl?: string | null);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resume hint for a retry: the item ALREADY EXISTS at the target with this id.
|
|
131
|
+
*
|
|
132
|
+
* Part of the interface rather than one target's detail, because it is inherent to any
|
|
133
|
+
* target that creates an item and attaches files in separate calls (Kolibri, Jira and
|
|
134
|
+
* Linear all do) — the window between those calls is exactly where a retry can
|
|
135
|
+
* duplicate a ticket. A target whose create is idempotent, as Kolibri's is, needs it
|
|
136
|
+
* only as a hint; one that creates atomically ignores it entirely.
|
|
137
|
+
*/
|
|
138
|
+
export interface FeedbackResume {
|
|
139
|
+
externalId: string;
|
|
140
|
+
}
|
|
141
|
+
export interface FeedbackTarget extends FeedbackTargetDescriptor {
|
|
142
|
+
/**
|
|
143
|
+
* Create the item and attach the report's files.
|
|
144
|
+
*
|
|
145
|
+
* Contract: throws FeedbackTargetError on failure — it does NOT retry internally.
|
|
146
|
+
* Retry belongs to the caller's durable queue, which is where the backoff, the
|
|
147
|
+
* attempt budget and the operator visibility live. A target that retried on its own
|
|
148
|
+
* would be a second, invisible retry policy layered under the real one.
|
|
149
|
+
*
|
|
150
|
+
* When `resume` is given the item must NOT be created again — only the remaining
|
|
151
|
+
* work (the attachment upload) is retried.
|
|
152
|
+
*/
|
|
153
|
+
submit(report: FeedbackReport, resume?: FeedbackResume): Promise<FeedbackSubmission>;
|
|
154
|
+
}
|
|
155
|
+
/** The descriptor of a live target — what crosses to the browser. */
|
|
156
|
+
export declare function describeTarget(target: FeedbackTargetDescriptor): FeedbackTargetDescriptor;
|
|
157
|
+
/** Injected so HTTP targets are unit-testable without a network. */
|
|
158
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The feedback-target seam — the one contract the capture/UI side and the delivery
|
|
2
|
+
// side both depend on, and the reason a different issue tracker can replace the
|
|
3
|
+
// configured one without either of them changing.
|
|
4
|
+
//
|
|
5
|
+
// Consumers depend ONLY on this interface and the normalized report shape below,
|
|
6
|
+
// never on a concrete HTTP client. `KolibriFeedbackTarget` is the real
|
|
7
|
+
// implementation; `FakeFeedbackTarget` drives tests and local development. A future
|
|
8
|
+
// Linear/Jira target slots in next to it and nothing on the capture side is touched.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately NOT on this interface: update/comment/close/list. Feedback is
|
|
11
|
+
// one-directional (the app posts, humans work the ticket in the tracker's own UI), so
|
|
12
|
+
// declaring round-trip methods would be inventing a contract no caller needs.
|
|
13
|
+
/**
|
|
14
|
+
* A target failure, classified.
|
|
15
|
+
*
|
|
16
|
+
* `permanent` is what stops a retry policy burning its whole attempt budget on a
|
|
17
|
+
* misconfigured project id, and equally what stops a transient 502 being written off
|
|
18
|
+
* as a dead report. The rule: a 4xx that no amount of retrying will fix (auth,
|
|
19
|
+
* not-found, unprocessable) is permanent; 5xx, network and timeout are not.
|
|
20
|
+
*
|
|
21
|
+
* `externalId` is set when the failure happened AFTER the item was created (i.e. the
|
|
22
|
+
* attachment upload failed). A durable caller persists it so the retry resumes the
|
|
23
|
+
* upload instead of creating a second ticket.
|
|
24
|
+
*/
|
|
25
|
+
export class FeedbackTargetError extends Error {
|
|
26
|
+
permanent;
|
|
27
|
+
status;
|
|
28
|
+
externalId;
|
|
29
|
+
externalUrl;
|
|
30
|
+
constructor(message, permanent, status, externalId, externalUrl) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'FeedbackTargetError';
|
|
33
|
+
this.permanent = permanent;
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.externalId = externalId;
|
|
36
|
+
this.externalUrl = externalUrl;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** The descriptor of a live target — what crosses to the browser. */
|
|
40
|
+
export function describeTarget(target) {
|
|
41
|
+
return { name: target.name, label: target.label, trustBoundary: target.trustBoundary };
|
|
42
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// @kolibri-day/feedback/kolibri — Kolibri as a feedback target. Runs anywhere fetch does.
|
|
2
|
+
export { KolibriFeedbackTarget, KOLIBRI_TARGET, UUID_PATTERN, isPermanentStatus, } from "./adapters/kolibri/target.js";
|
|
3
|
+
export { kolibriConfigFromEnv, KOLIBRI_ENV_VARS } from "./adapters/kolibri/env.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `kolibri-feedback-preflight` command. All of it lives in
|
|
3
|
+
// adapters/kolibri/preflight.ts; this file only hands it the process.
|
|
4
|
+
import { runPreflight } from "./adapters/kolibri/preflight.js";
|
|
5
|
+
runPreflight(process.env, process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
6
|
+
console.error(error);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { FeedbackReporterProvider, useFeedback, useFeedbackReporter, type FeedbackActions, type FeedbackReporterState, type FeedbackReporterProviderProps, } from './adapters/react/reporter.js';
|
|
2
|
+
export type { CaptureState } from './adapters/capture/reporter.js';
|
|
3
|
+
export type { CaptureResult } from './adapters/capture/capture.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { resolveFeedbackTarget, describeFeedbackTarget, type ResolvedFeedbackTarget, type ResolveOptions, } from './wiring.js';
|
|
2
|
+
export { readFeedbackForm, parseFeedbackInput, sanitizeClientContext, FeedbackInputError, DEFAULT_LIMITS, type FeedbackLimits, type FeedbackInput, type FeedbackScreenshotInput, type ParsedFeedbackInput, type ParseOptions, } from './kernel/feedback/input.js';
|
|
3
|
+
export { dispatchFeedback, MAX_ERROR_LENGTH, type FeedbackStore, type StoredFeedback, type StoredFeedbackStatus, type FeedbackSubmittedUpdate, type FeedbackFailureUpdate, type DispatchResult, type DispatchOptions, type DispatchLogger, } from './kernel/feedback/dispatch.js';
|
|
4
|
+
export type { Env } from './adapters/kolibri/env.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @kolibri-day/feedback/server — what runs next to the app's own session and storage:
|
|
2
|
+
// resolving the configured target, reading and validating an incoming report, and
|
|
3
|
+
// delivering a stored report with the right retry semantics.
|
|
4
|
+
export { resolveFeedbackTarget, describeFeedbackTarget, } from "./wiring.js";
|
|
5
|
+
export { readFeedbackForm, parseFeedbackInput, sanitizeClientContext, FeedbackInputError, DEFAULT_LIMITS, } from "./kernel/feedback/input.js";
|
|
6
|
+
export { dispatchFeedback, MAX_ERROR_LENGTH, } from "./kernel/feedback/dispatch.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FeedbackTarget, FeedbackTargetDescriptor, FetchLike } from './kernel/feedback/types.js';
|
|
2
|
+
import { type Env } from './adapters/kolibri/env.js';
|
|
3
|
+
import { type FeedbackMessages } from './kernel/feedback/messages.js';
|
|
4
|
+
export interface ResolvedFeedbackTarget {
|
|
5
|
+
target: FeedbackTarget;
|
|
6
|
+
/** Set when the target is a placeholder — log it once at boot. */
|
|
7
|
+
warning: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface ResolveOptions {
|
|
10
|
+
/** Language of the configuration warnings, the ticket body and the target errors. Default: German. */
|
|
11
|
+
messages?: FeedbackMessages;
|
|
12
|
+
/** Display labels for the app's own kinds, used in ticket titles. */
|
|
13
|
+
kindLabels?: Readonly<Record<string, string>>;
|
|
14
|
+
/** Per-request timeout for HTTP targets. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** For tests. */
|
|
17
|
+
fetch?: FetchLike;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the configured target.
|
|
21
|
+
*
|
|
22
|
+
* NEVER THROWS. A misconfiguration produces an UnconfiguredFeedbackTarget plus a
|
|
23
|
+
* warning, not an exception: failing a worker's boot over a feedback variable would
|
|
24
|
+
* take every other job in that process down with it. The failure still surfaces — at
|
|
25
|
+
* boot as the warning, and per report as a permanent failure naming the problem.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveFeedbackTarget(env?: Env, options?: ResolveOptions): ResolvedFeedbackTarget;
|
|
28
|
+
/**
|
|
29
|
+
* Where would a screenshot land? — answered WITHOUT credentials.
|
|
30
|
+
*
|
|
31
|
+
* A web tier legitimately holds no tracker token: it only needs FEEDBACK_TARGET so the
|
|
32
|
+
* reporter's notice can name the destination. Resolving from that alone would yield
|
|
33
|
+
* the unconfigured placeholder and a notice like "is stored in your self-hosted no
|
|
34
|
+
* target system" — broken, and a false statement about where data goes. So a known
|
|
35
|
+
* choice is described from its static descriptor; only an unset or unknown choice
|
|
36
|
+
* falls through to the resolver, which is where "no target" is the honest answer.
|
|
37
|
+
*/
|
|
38
|
+
export declare function describeFeedbackTarget(env?: Env, options?: Pick<ResolveOptions, 'messages'>): FeedbackTargetDescriptor;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FakeFeedbackTarget, UnconfiguredFeedbackTarget } from "./kernel/feedback/fake-target.js";
|
|
2
|
+
import { KolibriFeedbackTarget, KOLIBRI_TARGET } from "./adapters/kolibri/target.js";
|
|
3
|
+
import { kolibriConfigFromEnv } from "./adapters/kolibri/env.js";
|
|
4
|
+
import { en } from "./kernel/feedback/messages.js";
|
|
5
|
+
const KNOWN_TARGETS = ['kolibri', 'fake'];
|
|
6
|
+
function processEnv() {
|
|
7
|
+
return globalThis.process?.env ?? {};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the configured target.
|
|
11
|
+
*
|
|
12
|
+
* NEVER THROWS. A misconfiguration produces an UnconfiguredFeedbackTarget plus a
|
|
13
|
+
* warning, not an exception: failing a worker's boot over a feedback variable would
|
|
14
|
+
* take every other job in that process down with it. The failure still surfaces — at
|
|
15
|
+
* boot as the warning, and per report as a permanent failure naming the problem.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveFeedbackTarget(env = processEnv(), options = {}) {
|
|
18
|
+
const messages = options.messages ?? en;
|
|
19
|
+
const choice = (env.FEEDBACK_TARGET ?? '').trim().toLowerCase();
|
|
20
|
+
const isProduction = env.NODE_ENV === 'production';
|
|
21
|
+
const unconfigured = (reason) => ({
|
|
22
|
+
target: new UnconfiguredFeedbackTarget(reason, messages.targets.unconfigured),
|
|
23
|
+
warning: reason,
|
|
24
|
+
});
|
|
25
|
+
if (choice === '' || choice === 'fake') {
|
|
26
|
+
// Unset in production is NOT treated as "use the fake". A silently-swallowing
|
|
27
|
+
// target in production is the one outcome worse than a loud failure: people go on
|
|
28
|
+
// filing reports that reach nobody.
|
|
29
|
+
if (choice === '' && isProduction)
|
|
30
|
+
return unconfigured(messages.config.targetUnset);
|
|
31
|
+
return {
|
|
32
|
+
target: new FakeFeedbackTarget({ label: messages.targets.fake }),
|
|
33
|
+
warning: isProduction ? messages.config.fakeInProduction : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (choice === 'kolibri') {
|
|
37
|
+
const read = kolibriConfigFromEnv(env, messages);
|
|
38
|
+
if (!read.ok)
|
|
39
|
+
return unconfigured(read.reason);
|
|
40
|
+
return {
|
|
41
|
+
target: new KolibriFeedbackTarget({ ...read.config, messages, kindLabels: options.kindLabels, timeoutMs: options.timeoutMs }, options.fetch),
|
|
42
|
+
// A configured Kolibri target is only as good as its preflight — the token's
|
|
43
|
+
// write scope over the upload route cannot be assumed (see the README).
|
|
44
|
+
warning: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return unconfigured(messages.config.unknownTarget(choice, KNOWN_TARGETS));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Where would a screenshot land? — answered WITHOUT credentials.
|
|
51
|
+
*
|
|
52
|
+
* A web tier legitimately holds no tracker token: it only needs FEEDBACK_TARGET so the
|
|
53
|
+
* reporter's notice can name the destination. Resolving from that alone would yield
|
|
54
|
+
* the unconfigured placeholder and a notice like "is stored in your self-hosted no
|
|
55
|
+
* target system" — broken, and a false statement about where data goes. So a known
|
|
56
|
+
* choice is described from its static descriptor; only an unset or unknown choice
|
|
57
|
+
* falls through to the resolver, which is where "no target" is the honest answer.
|
|
58
|
+
*/
|
|
59
|
+
export function describeFeedbackTarget(env = processEnv(), options = {}) {
|
|
60
|
+
const messages = options.messages ?? en;
|
|
61
|
+
const choice = (env.FEEDBACK_TARGET ?? '').trim().toLowerCase();
|
|
62
|
+
if (choice === KOLIBRI_TARGET.name)
|
|
63
|
+
return { ...KOLIBRI_TARGET };
|
|
64
|
+
if (choice === 'fake')
|
|
65
|
+
return { name: 'fake', label: messages.targets.fake, trustBoundary: 'self_hosted' };
|
|
66
|
+
const { target } = resolveFeedbackTarget(env, { messages });
|
|
67
|
+
return { name: target.name, label: target.label, trustBoundary: target.trustBoundary };
|
|
68
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolibri-day/feedback",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "In-app feedback with a screenshot of the current view, delivered to Kolibri — or to any tracker behind the same target interface.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kolibri contributors",
|
|
7
|
+
"homepage": "https://github.com/LucaFrankfurt/AIfirstPMO/tree/main/packages/feedback#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/LucaFrankfurt/AIfirstPMO.git",
|
|
11
|
+
"directory": "packages/feedback"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/LucaFrankfurt/AIfirstPMO/issues",
|
|
14
|
+
"keywords": ["feedback", "bug-report", "screenshot", "kolibri", "issue-tracker", "react"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./dist/cjs/index.js",
|
|
18
|
+
"module": "./dist/esm/index.js",
|
|
19
|
+
"types": "./dist/cjs/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" },
|
|
23
|
+
"require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" }
|
|
24
|
+
},
|
|
25
|
+
"./kolibri": {
|
|
26
|
+
"import": { "types": "./dist/esm/kolibri.d.ts", "default": "./dist/esm/kolibri.js" },
|
|
27
|
+
"require": { "types": "./dist/cjs/kolibri.d.ts", "default": "./dist/cjs/kolibri.js" }
|
|
28
|
+
},
|
|
29
|
+
"./server": {
|
|
30
|
+
"import": { "types": "./dist/esm/server.d.ts", "default": "./dist/esm/server.js" },
|
|
31
|
+
"require": { "types": "./dist/cjs/server.d.ts", "default": "./dist/cjs/server.js" }
|
|
32
|
+
},
|
|
33
|
+
"./browser": {
|
|
34
|
+
"import": { "types": "./dist/esm/browser.d.ts", "default": "./dist/esm/browser.js" },
|
|
35
|
+
"require": { "types": "./dist/cjs/browser.d.ts", "default": "./dist/cjs/browser.js" }
|
|
36
|
+
},
|
|
37
|
+
"./react": {
|
|
38
|
+
"import": { "types": "./dist/esm/react.d.ts", "default": "./dist/esm/react.js" },
|
|
39
|
+
"require": { "types": "./dist/cjs/react.d.ts", "default": "./dist/cjs/react.js" }
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"typesVersions": {
|
|
44
|
+
"*": {
|
|
45
|
+
"kolibri": ["./dist/cjs/kolibri.d.ts"],
|
|
46
|
+
"server": ["./dist/cjs/server.d.ts"],
|
|
47
|
+
"browser": ["./dist/cjs/browser.d.ts"],
|
|
48
|
+
"react": ["./dist/cjs/react.d.ts"]
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"bin": {
|
|
52
|
+
"kolibri-feedback-preflight": "dist/cjs/preflight-cli.js"
|
|
53
|
+
},
|
|
54
|
+
"files": ["dist", "fixtures", "README.md", "LICENSE"],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "rm -rf dist && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node scripts/postbuild.mjs",
|
|
57
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
58
|
+
"test": "node --test test/*.test.ts",
|
|
59
|
+
"prepublishOnly": "npm run build"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"html2canvas": "^1.4.1",
|
|
63
|
+
"modern-screenshot": "^4.7.0",
|
|
64
|
+
"react": "^18.2.0 || ^19.0.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"html2canvas": { "optional": true },
|
|
68
|
+
"modern-screenshot": { "optional": true },
|
|
69
|
+
"react": { "optional": true }
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@types/react": "^19.2.0",
|
|
73
|
+
"html2canvas": "^1.4.1",
|
|
74
|
+
"modern-screenshot": "^4.7.0",
|
|
75
|
+
"react": "^19.2.0",
|
|
76
|
+
"typescript": "^5.9.2"
|
|
77
|
+
},
|
|
78
|
+
"engines": {
|
|
79
|
+
"node": ">=20"
|
|
80
|
+
},
|
|
81
|
+
"publishConfig": {
|
|
82
|
+
"access": "public"
|
|
83
|
+
}
|
|
84
|
+
}
|