@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,28 @@
1
+ import { screenshotFilename } from "../../kernel/feedback/files.js";
2
+ /** Build the multipart body. Identity is deliberately not a field — the server takes it from the session. */
3
+ export function buildFeedbackForm(input) {
4
+ const form = new FormData();
5
+ form.set('kind', input.kind);
6
+ form.set('message', input.message);
7
+ form.set('clientContext', JSON.stringify(input.clientContext ?? {}));
8
+ if (input.screenshot)
9
+ form.set('screenshot', input.screenshot, screenshotFilename(input.screenshot.type));
10
+ return form;
11
+ }
12
+ /**
13
+ * POST the report. Resolves with the server's verdict; REJECTS only when the request
14
+ * never got an answer (offline, DNS, CORS) — the caller words that case differently
15
+ * ("network error, try again") from a refusal.
16
+ *
17
+ * Success means the server ACCEPTED the report — with the durable pipeline, that it is
18
+ * stored, not that it reached the tracker. Word the confirmation accordingly.
19
+ */
20
+ export async function submitFeedback(endpoint, input, options = {}) {
21
+ const doFetch = options.fetch ?? fetch;
22
+ const res = await doFetch(endpoint, { method: 'POST', body: buildFeedbackForm(input) });
23
+ const body = (await res.json().catch(() => null));
24
+ if (!res.ok) {
25
+ return { ok: false, status: res.status, error: typeof body?.error === 'string' ? body.error : null };
26
+ }
27
+ return { ok: true, id: typeof body?.id === 'string' ? body.id : null };
28
+ }
@@ -0,0 +1,16 @@
1
+ import { type KolibriConfig } from './target.js';
2
+ import { type FeedbackMessages } from '../../kernel/feedback/messages.js';
3
+ export type Env = Readonly<Record<string, string | undefined>>;
4
+ export declare const KOLIBRI_ENV_VARS: readonly ["KOLIBRI_API_URL", "KOLIBRI_TOKEN", "KOLIBRI_WORKSPACE_ID", "KOLIBRI_PROJECT_ID"];
5
+ export type KolibriEnvResult = {
6
+ ok: true;
7
+ config: Pick<KolibriConfig, 'apiUrl' | 'token' | 'workspaceId' | 'projectId'>;
8
+ } | {
9
+ ok: false;
10
+ reason: string;
11
+ };
12
+ /**
13
+ * Read and validate the four variables. Never throws: a problem comes back as a
14
+ * reason worded for an operator.
15
+ */
16
+ export declare function kolibriConfigFromEnv(env: Env, messages?: FeedbackMessages): KolibriEnvResult;
@@ -0,0 +1,36 @@
1
+ // Reading a Kolibri configuration from environment variables.
2
+ //
3
+ // The variable names are the package's convention (KOLIBRI_API_URL, KOLIBRI_TOKEN,
4
+ // KOLIBRI_WORKSPACE_ID, KOLIBRI_PROJECT_ID) so every app that adopts it is configured
5
+ // — and preflighted — the same way.
6
+ import { UUID_PATTERN } from "./target.js";
7
+ import { en } from "../../kernel/feedback/messages.js";
8
+ export const KOLIBRI_ENV_VARS = [
9
+ 'KOLIBRI_API_URL',
10
+ 'KOLIBRI_TOKEN',
11
+ 'KOLIBRI_WORKSPACE_ID',
12
+ 'KOLIBRI_PROJECT_ID',
13
+ ];
14
+ /**
15
+ * Read and validate the four variables. Never throws: a problem comes back as a
16
+ * reason worded for an operator.
17
+ */
18
+ export function kolibriConfigFromEnv(env, messages = en) {
19
+ const apiUrl = env.KOLIBRI_API_URL?.trim() ?? '';
20
+ const token = env.KOLIBRI_TOKEN?.trim() ?? '';
21
+ const workspaceId = env.KOLIBRI_WORKSPACE_ID?.trim() ?? '';
22
+ const projectId = env.KOLIBRI_PROJECT_ID?.trim() ?? '';
23
+ const missing = KOLIBRI_ENV_VARS.filter((name) => !env[name]?.trim());
24
+ if (missing.length > 0) {
25
+ return { ok: false, reason: messages.config.missingVariables('kolibri', missing) };
26
+ }
27
+ // The workspace is checked first because it is the one that decides WHO can see the
28
+ // screenshots, so a typo in it is the more expensive of the two.
29
+ if (!UUID_PATTERN.test(workspaceId)) {
30
+ return { ok: false, reason: messages.config.notUuid('KOLIBRI_WORKSPACE_ID', 'workspace', workspaceId) };
31
+ }
32
+ if (!UUID_PATTERN.test(projectId)) {
33
+ return { ok: false, reason: messages.config.notUuid('KOLIBRI_PROJECT_ID', 'project', projectId) };
34
+ }
35
+ return { ok: true, config: { apiUrl, token, workspaceId, projectId } };
36
+ }
@@ -0,0 +1,6 @@
1
+ import { type Env } from './env.js';
2
+ /**
3
+ * Run the six checks against the instance the environment names, printing as it goes.
4
+ * Resolves with the exit code: 0 when task creation and upload both passed.
5
+ */
6
+ export declare function runPreflight(env: Env, args: readonly string[]): Promise<number>;
@@ -0,0 +1,533 @@
1
+ // Kolibri preflight — the gate before FEEDBACK_TARGET=kolibri goes live.
2
+ //
3
+ // WHY THIS EXISTS, and why no unit test replaces it. Two properties of the LIVE
4
+ // instance decide whether delivery works, and neither is knowable from the code:
5
+ //
6
+ // 1. Does the token carry the `write` scope over BOTH routes? Kolibri refuses a
7
+ // read-only token on every write, the file upload included — and the upload is a
8
+ // hand-written route, not a CRUD one. Stubbed-fetch tests cannot answer it, and
9
+ // assuming the answer is how you find out in production that every report arrives
10
+ // without its screenshot.
11
+ // 2. WHO can see the screenshots? The feedback project's members — but only on a
12
+ // Kolibri that scopes attachments by the project of the task they hang off. Older
13
+ // instances checked workspace membership alone, so a private project protected the
14
+ // task text and not its screenshot. Step 2 prints the member list for a human to
15
+ // confirm, and that is not a formality: it is the one check a code review cannot
16
+ // perform on anyone's behalf.
17
+ //
18
+ // Steps 3 and 4 deliberately run through the REAL KolibriFeedbackTarget rather than
19
+ // reimplementing the calls, so what is verified is the production delivery path.
20
+ //
21
+ // Usage:
22
+ // FEEDBACK_TARGET=kolibri \
23
+ // KOLIBRI_API_URL=https://kolibri.example.com \
24
+ // KOLIBRI_TOKEN=kol_… \
25
+ // KOLIBRI_WORKSPACE_ID=<uuid> \
26
+ // KOLIBRI_PROJECT_ID=<uuid> \
27
+ // npx kolibri-feedback-preflight [--cleanup] [--image <path>]
28
+ //
29
+ // Flags:
30
+ // --cleanup delete the test task when finished (default: leave it for review)
31
+ // --image <path> use a different attachment instead of the bundled test image
32
+ //
33
+ // The token is a secret: pass it as an environment variable, never as an argument
34
+ // (arguments show up in shell history and in `ps`).
35
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
36
+ import { dirname, join } from 'node:path';
37
+ import { randomUUID } from 'node:crypto';
38
+ import { FeedbackTargetError } from "../../kernel/feedback/types.js";
39
+ import { KolibriFeedbackTarget } from "./target.js";
40
+ import { kolibriConfigFromEnv } from "./env.js";
41
+ const PACKAGE_NAME = '@kolibri-day/feedback';
42
+ /**
43
+ * The package root, found by walking up from the running script — the same answer
44
+ * whether it runs from dist/cjs (the installed bin) or from src (a checkout), and
45
+ * without `__dirname`, which an ES-module build does not have.
46
+ *
47
+ * The script path is resolved through symlinks first: npm installs a bin as a link in
48
+ * node_modules/.bin, and walking up from the link would search the wrong tree.
49
+ */
50
+ function packageRoot() {
51
+ let dir;
52
+ try {
53
+ dir = dirname(realpathSync(process.argv[1] ?? '.'));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ for (let i = 0; i < 8; i++) {
59
+ const manifest = join(dir, 'package.json');
60
+ if (existsSync(manifest)) {
61
+ const name = JSON.parse(readFileSync(manifest, 'utf8')).name;
62
+ if (name === PACKAGE_NAME)
63
+ return dir;
64
+ }
65
+ dir = dirname(dir);
66
+ }
67
+ return null;
68
+ }
69
+ const ok = (s) => console.log(` \x1b[32m✓\x1b[0m ${s}`);
70
+ const bad = (s) => console.log(` \x1b[31m✗\x1b[0m ${s}`);
71
+ const warn = (s) => console.log(` \x1b[33m!\x1b[0m ${s}`);
72
+ const info = (s) => console.log(` ${s}`);
73
+ const step = (n, s) => console.log(`\n[${n}/6] ${s}`);
74
+ /**
75
+ * Kolibri returns lists in one of two shapes depending on the route (`{tasks: [...]}`
76
+ * per openapi.json, a bare array in practice). Reading both is three lines and removes
77
+ * a way for this script to report "nothing there" at a full instance.
78
+ */
79
+ function rows(body, key) {
80
+ if (Array.isArray(body))
81
+ return body;
82
+ const wrapped = body?.[key];
83
+ return Array.isArray(wrapped) ? wrapped : [];
84
+ }
85
+ class Preflight {
86
+ env;
87
+ cleanup;
88
+ imagePath;
89
+ results = {};
90
+ session = {};
91
+ constructor(env, args) {
92
+ this.env = env;
93
+ this.cleanup = args.includes('--cleanup');
94
+ const imageAt = args.indexOf('--image');
95
+ const given = imageAt >= 0 ? args[imageAt + 1] : undefined;
96
+ // A realistic ~380 KB WebP — the upper end of what the browser capture produces.
97
+ // Shipped rather than generated, so no image encoder is needed here.
98
+ const root = packageRoot();
99
+ this.imagePath = given ?? (root ? join(root, 'fixtures', 'preflight-screenshot.webp') : '');
100
+ }
101
+ get base() {
102
+ return (this.env.KOLIBRI_API_URL ?? '').replace(/\/+$/, '');
103
+ }
104
+ get ws() {
105
+ return this.env.KOLIBRI_WORKSPACE_ID ?? '';
106
+ }
107
+ /** Authorized GET against the API. Returns the raw Response — callers read the status. */
108
+ get(path) {
109
+ return fetch(path.startsWith('http') ? path : `${this.base}${path}`, {
110
+ headers: { Authorization: `Bearer ${this.env.KOLIBRI_TOKEN ?? ''}` },
111
+ signal: AbortSignal.timeout(20_000),
112
+ });
113
+ }
114
+ /**
115
+ * Does this rejection look like it came from something OTHER than Kolibri?
116
+ *
117
+ * Kolibri answers every error with a JSON envelope ({"error": …, "message": …}). A
118
+ * corporate proxy, a VPN gateway or a WAF in front of the instance answers with HTML,
119
+ * plain text, or nothing — typically 403. Without this check the script would blame
120
+ * the token for what is really an unreachable host, and send someone off re-minting
121
+ * tokens for no reason.
122
+ */
123
+ async gatewayHint(res) {
124
+ const body = await res.text().catch(() => '');
125
+ const contentType = res.headers.get('content-type') ?? '';
126
+ const looksLikeKolibri = contentType.includes('json') || body.trimStart().startsWith('{');
127
+ if (looksLikeKolibri)
128
+ return false;
129
+ warn('This answer probably did NOT come from Kolibri.');
130
+ info(`Content-Type: ${contentType || '(none)'}; body: ${body.slice(0, 120).trim() || '(empty)'}`);
131
+ info('Kolibri answers every error with JSON ({"error": …, "message": …}).');
132
+ info('A proxy, VPN gateway or WAF in front of it does not.');
133
+ info('→ Check that the host is reachable from here (VPN, network) before changing the token:');
134
+ // "$KOLIBRI_TOKEN" is printed as a variable REFERENCE, so the line is copy-pasteable
135
+ // without the secret itself ever appearing on screen.
136
+ info(` curl -i -H "Authorization: Bearer $KOLIBRI_TOKEN" ${this.base}/api/session`);
137
+ return true;
138
+ }
139
+ async stepSession() {
140
+ step(1, 'Token and account');
141
+ const res = await this.get('/api/session').catch((e) => (e instanceof Error ? e : new Error(String(e))));
142
+ if (res instanceof Error) {
143
+ bad(`Instance unreachable: ${res.message}`);
144
+ info(`Checked: ${this.base}`);
145
+ info('→ Check KOLIBRI_API_URL (the origin, NOT /api) and network/VPN access.');
146
+ this.results.auth = 'fail';
147
+ return false;
148
+ }
149
+ if (res.status === 401) {
150
+ bad('Token rejected (HTTP 401) — invalid, revoked, expired or copied wrongly');
151
+ info('→ Create a new token in Kolibri (Settings → API & MCP).');
152
+ this.results.auth = 'fail';
153
+ return false;
154
+ }
155
+ if (res.status !== 200) {
156
+ bad(`Unexpected status ${res.status} from GET /api/session`);
157
+ await this.gatewayHint(res);
158
+ this.results.auth = 'fail';
159
+ return false;
160
+ }
161
+ this.session = ((await res.json().catch(() => ({}))) ?? {});
162
+ const user = this.session.user;
163
+ ok(`Token is valid — account: ${user?.name ?? '(no name)'} <${user?.email ?? '?'}>`);
164
+ // A CONFINED token reports exactly one workspace, because Kolibri narrows the
165
+ // membership map behind every gate when the token carries the flag. So this count
166
+ // is the cheapest honest test of whether confinement actually took, and it fails
167
+ // loudly rather than leaving an over-broad credential in place.
168
+ const all = this.session.workspaces ?? [];
169
+ const only = all.length === 1 ? all[0] : undefined;
170
+ if (only && only.id === this.ws) {
171
+ ok(`Token is confined to one workspace: ${only.name}`);
172
+ }
173
+ else if (all.length > 1) {
174
+ bad(`The token sees ${all.length} workspaces — it is NOT confined.`);
175
+ for (const w of all)
176
+ info(` ${w.name}${w.slug ? ` (${w.slug})` : ''} — ${w.role ?? '?'}`);
177
+ info('');
178
+ info('Without `confined` a token carries the full reach of its account. When the');
179
+ info('feedback project lives in a shared workspace, a dedicated account is not enough');
180
+ info('on its own — the restriction has to be on the token.');
181
+ info('→ Mint it again (README, "Kolibri setup"):');
182
+ info(` POST /api/tokens {"workspaceId":"${this.ws}","confined":true,"scopes":"write"}`);
183
+ this.results.auth = 'fail';
184
+ return false;
185
+ }
186
+ else {
187
+ warn('The token sees no workspace, or a different one than configured.');
188
+ for (const w of all)
189
+ info(` ${w.id} → ${w.name}`);
190
+ info('→ Check KOLIBRI_WORKSPACE_ID and what the token is bound to.');
191
+ }
192
+ this.results.auth = 'pass';
193
+ return true;
194
+ }
195
+ async stepWorkspaceAndProject() {
196
+ step(2, 'Workspace, project and audience');
197
+ const workspace = (this.session.workspaces ?? []).find((w) => w.id === this.ws);
198
+ if (!workspace) {
199
+ bad(`KOLIBRI_WORKSPACE_ID (${this.ws}) is not one of this account's workspaces`);
200
+ info('Visible workspaces (id → name):');
201
+ for (const w of this.session.workspaces ?? [])
202
+ info(` ${w.id} → ${w.name}`);
203
+ info('→ Set KOLIBRI_WORKSPACE_ID to the UUID of the feedback workspace.');
204
+ this.results.project = 'fail';
205
+ return false;
206
+ }
207
+ ok(`Workspace: ${workspace.name} (role: ${workspace.role ?? '?'})`);
208
+ const projRes = await this.get(`/api/workspaces/${this.ws}/projects`);
209
+ if (projRes.status !== 200) {
210
+ bad(`Could not list projects (HTTP ${projRes.status})`);
211
+ await this.gatewayHint(projRes);
212
+ this.results.project = 'fail';
213
+ return false;
214
+ }
215
+ const projects = rows(await projRes.json().catch(() => []), 'projects');
216
+ const project = projects.find((p) => p.id === this.env.KOLIBRI_PROJECT_ID);
217
+ if (!project) {
218
+ bad(`KOLIBRI_PROJECT_ID (${this.env.KOLIBRI_PROJECT_ID ?? ''}) is not visible to this account`);
219
+ // Two very different causes, one symptom — and the second is the likelier one for
220
+ // a fresh setup, so it is named first. A private project simply does not appear in
221
+ // this list for a non-member, which reads exactly like a wrong id.
222
+ info('Two possible causes:');
223
+ info(' 1. The account is NOT a member of the project. A private project does not');
224
+ info(' appear for non-members at all. Writing would still work — task create and');
225
+ info(' upload do not ask about the project — but reading back does not, and that is');
226
+ info(' what step 5 does. → Add the account to the project (README, "Kolibri setup").');
227
+ info(' 2. The id is wrong. Projects visible in this workspace:');
228
+ for (const p of projects)
229
+ info(` ${p.id} → ${p.key ?? '—'} → ${p.name ?? '(no name)'}`);
230
+ info(' → Set KOLIBRI_PROJECT_ID to the UUID, NOT the key.');
231
+ this.results.project = 'fail';
232
+ return false;
233
+ }
234
+ // The point of this step: a human confirms the NAME, because a wrong-but-valid id
235
+ // would silently file screenshots into the wrong board.
236
+ const isPrivate = project.visibility === 'private';
237
+ console.log(`\n Project: \x1b[1m${project.name ?? '(no name)'}\x1b[0m (${project.key ?? '—'}) — ` +
238
+ `${isPrivate ? 'private' : 'public within the workspace'}\n`);
239
+ info('↑ PLEASE CHECK: is this the project feedback should land in?');
240
+ // Who can open a screenshot is READ here, not asked about: whoever can see the
241
+ // project, and the project itself says which set that is.
242
+ const memRes = await this.get(`/api/workspaces/${this.ws}/members`);
243
+ const workspaceMembers = memRes.status === 200
244
+ ? rows(await memRes.json().catch(() => []), 'members')
245
+ : null;
246
+ if (!workspaceMembers) {
247
+ warn(`Could not list members (HTTP ${memRes.status}) — please check in the web app.`);
248
+ info('Whoever can see the project can open every screenshot in it.');
249
+ this.results.project = 'pass';
250
+ return true;
251
+ }
252
+ console.log('');
253
+ if (!isPrivate) {
254
+ warn(`The project is PUBLIC — all ${workspaceMembers.length} workspace members can open every screenshot:`);
255
+ for (const m of workspaceMembers)
256
+ info(` ${m.name ?? '?'} <${m.email ?? '?'}> — ${m.role ?? '?'}`);
257
+ info('');
258
+ info('That is fine if the workspace is exactly that audience anyway — but then the');
259
+ info('WORKSPACE membership is the privacy boundary: inviting someone to it also hands');
260
+ info('them every feedback screenshot.');
261
+ }
262
+ else {
263
+ const pmRes = await this.get(`/api/workspaces/${this.ws}/project-members?project_id=${encodeURIComponent(project.id)}`);
264
+ const projectMembers = pmRes.status === 200
265
+ ? rows(await pmRes.json().catch(() => []), 'project-members')
266
+ : [];
267
+ const byId = new Map(workspaceMembers.map((m) => [m.id, m]));
268
+ warn(`The project is private — ${projectMembers.length} person(s) can open the screenshots:`);
269
+ for (const pm of projectMembers) {
270
+ const who = byId.get(pm.user_id);
271
+ info(` ${who?.name ?? pm.user_id ?? '?'} <${who?.email ?? '?'}> — ${pm.role ?? '?'}`);
272
+ }
273
+ if (projectMembers.length === 0) {
274
+ info(' (none — Kolibri has no screen for this, see README, "Kolibri setup")');
275
+ }
276
+ }
277
+ info('');
278
+ info('↑ PLEASE CHECK: may every one of these people see whatever a screenshot of the app can show?');
279
+ this.results.project = 'pass';
280
+ return true;
281
+ }
282
+ async stepCreateAndUpload(target, reportId) {
283
+ const image = readFileSync(this.imagePath);
284
+ const report = {
285
+ // A real UUID, not a fixed string: it becomes the TASK id (§G25), so a constant
286
+ // would make every preflight run upsert into the same task and pile attachments
287
+ // onto it — which is exactly the thing step 5 is trying to count.
288
+ id: reportId,
289
+ kind: 'OTHER',
290
+ message: `Kolibri preflight (${PACKAGE_NAME}).\n\n` +
291
+ 'Automatically created test entry: checks that task creation AND file upload work ' +
292
+ 'with the configured API token.\n\n' +
293
+ 'Contains NO real customer data. Safe to delete.',
294
+ reporter: { id: 'preflight', name: 'Feedback preflight' },
295
+ client: { url: 'https://preflight.invalid/', userAgent: 'kolibri-feedback-preflight' },
296
+ attachments: [{ filename: 'preflight-screenshot.webp', contentType: 'image/webp', bytes: new Uint8Array(image) }],
297
+ createdAt: new Date(),
298
+ };
299
+ step(3, 'Task creation');
300
+ info(`Attachment: ${this.imagePath.split('/').pop() ?? ''} (${Math.round(image.byteLength / 1024)} KB, image/webp)`);
301
+ try {
302
+ const result = await target.submit(report);
303
+ ok(`Task ${result.externalId} created`);
304
+ if (result.externalUrl)
305
+ info(`→ ${result.externalUrl}`);
306
+ this.results.create = 'pass';
307
+ step(4, 'File upload (the actual open question)');
308
+ ok(`${result.attachmentsUploaded} file(s) uploaded — the token covers the upload route`);
309
+ this.results.upload = 'pass';
310
+ return { taskId: result.externalId, uploaded: true, webUrl: result.externalUrl };
311
+ }
312
+ catch (e) {
313
+ const err = e instanceof FeedbackTargetError ? e : null;
314
+ const message = e instanceof Error ? e.message : String(e);
315
+ // externalId set ⇒ the task WAS created and the upload is what failed. That is the
316
+ // single most informative outcome this script can produce, and precisely the
317
+ // token-scope question it exists to answer.
318
+ if (err?.externalId) {
319
+ ok(`Task ${err.externalId} created`);
320
+ if (err.externalUrl)
321
+ info(`→ ${err.externalUrl}`);
322
+ this.results.create = 'pass';
323
+ step(4, 'File upload (the actual open question)');
324
+ bad(`Upload FAILED (HTTP ${err.status ?? '?'})`);
325
+ info(message.slice(0, 300));
326
+ if (err.status === 403) {
327
+ console.log('');
328
+ warn('Most likely cause: the token is READ-ONLY.');
329
+ info('Kolibri answers every write from such a token with 403 "Token is read-only" —');
330
+ info('the upload just like task creation. That step 3 passed speaks against it,');
331
+ info('which leaves the workspace role:');
332
+ info(' • a guest in the workspace may not upload (role ≥ member is required).');
333
+ info('→ Mint a token with scope `write` (Settings → API & MCP) and give the account');
334
+ info(' at least the `member` role in the feedback workspace.');
335
+ }
336
+ if (err.status === 413) {
337
+ console.log('');
338
+ warn('The file is larger than the instance upload limit.');
339
+ info('Read the limit (no authentication needed):');
340
+ info(` curl -s ${this.base}/api/config`);
341
+ info('→ Raise KOLIBRI_MAX_UPLOAD_MB, or check a reverse proxy in front of it.');
342
+ }
343
+ this.results.upload = 'fail';
344
+ return { taskId: err.externalId, uploaded: false, webUrl: err.externalUrl ?? null };
345
+ }
346
+ bad(`Task creation FAILED (HTTP ${err?.status ?? '?'})`);
347
+ info(message.slice(0, 300));
348
+ if (err?.status === 403) {
349
+ info('The token authenticates but may not write.');
350
+ info('→ Mint a token with scope `write` (Settings → API & MCP).');
351
+ }
352
+ if (err?.status === 400) {
353
+ info('Kolibri refused the body — almost always a wrong KOLIBRI_PROJECT_ID.');
354
+ info('→ It must be the project UUID, not the key. Step 2 lists both.');
355
+ }
356
+ this.results.create = 'fail';
357
+ this.results.upload = 'skip';
358
+ return { taskId: null, uploaded: false, webUrl: null };
359
+ }
360
+ }
361
+ async stepRetrieve(taskId, expectedBytes) {
362
+ step(5, 'Attachment stored and retrievable?');
363
+ // The LIST endpoint is the load-bearing check: it reports the STORED size, which
364
+ // confirms Kolibri actually persisted the bytes rather than accepting and dropping
365
+ // them.
366
+ const listRes = await this.get(`/api/workspaces/${this.ws}/attachments?task_id=${encodeURIComponent(taskId)}`);
367
+ if (listRes.status !== 200) {
368
+ warn(`Could not list attachments (HTTP ${listRes.status})`);
369
+ info('Not critical: the app only UPLOADS attachments, it never downloads them.');
370
+ info('→ Open the task in the web app instead and check the attachment there.');
371
+ this.results.retrieve = 'skip';
372
+ return;
373
+ }
374
+ const list = rows(await listRes.json().catch(() => []), 'attachments');
375
+ const attachment = list[0];
376
+ if (!attachment) {
377
+ bad('The API says the task has NO attachments — the upload was accepted but nothing was stored');
378
+ this.results.retrieve = 'fail';
379
+ return;
380
+ }
381
+ if (list.length > 1) {
382
+ // Not a failure, but it means Kolibri's upload deduplication did not do its job.
383
+ warn(`${list.length} attachments on a task with one screenshot — please report this.`);
384
+ }
385
+ const storedSize = attachment.size ?? 0;
386
+ ok(`Attachment stored: ${attachment.name ?? '?'} (${Math.round(storedSize / 1024)} KB, ${attachment.mime ?? '?'})`);
387
+ // Tolerance rather than exact equality. The failure mode worth catching is
388
+ // TRUNCATION — a size cap or a reverse-proxy body limit silently storing a partial
389
+ // file — which shows up as a dramatic shortfall, not a few bytes.
390
+ const drift = Math.abs(storedSize - expectedBytes);
391
+ if (drift === 0) {
392
+ ok(`Size matches the upload exactly (${expectedBytes} bytes) — nothing truncated`);
393
+ this.results.retrieve = 'pass';
394
+ }
395
+ else if (drift <= expectedBytes * 0.01) {
396
+ ok(`Size practically identical (${storedSize} instead of ${expectedBytes} bytes) — nothing truncated`);
397
+ this.results.retrieve = 'pass';
398
+ }
399
+ else {
400
+ bad(`The file was apparently TRUNCATED: uploaded ${expectedBytes} bytes, stored ${storedSize}`);
401
+ info(`→ Check for a size limit: curl -s ${this.base}/api/config (maxUploadBytes),`);
402
+ info(' or a reverse proxy in front of it. Screenshots are typically 150–400 KB.');
403
+ this.results.retrieve = 'fail';
404
+ return;
405
+ }
406
+ // Bonus: fetch it back. A stored-but-unreadable attachment would mean whoever triages
407
+ // the ticket sees a broken image.
408
+ if (!attachment.url) {
409
+ warn('The attachment row carries no URL — download skipped.');
410
+ return;
411
+ }
412
+ const dl = await this.get(attachment.url);
413
+ if (dl.status === 200) {
414
+ const bytes = new Uint8Array(await dl.arrayBuffer());
415
+ if (Math.abs(bytes.byteLength - expectedBytes) <= expectedBytes * 0.01) {
416
+ ok(`Download returns the whole file (${bytes.byteLength} bytes, ${dl.headers.get('content-type') ?? '?'})`);
417
+ }
418
+ else {
419
+ warn(`Download returns only ${bytes.byteLength} bytes (uploaded: ${expectedBytes})`);
420
+ }
421
+ }
422
+ else {
423
+ warn(`Attachment not retrievable (HTTP ${dl.status}) — the task will show a broken image.`);
424
+ info('Irrelevant for delivery (the app only uploads), but not for triage.');
425
+ }
426
+ }
427
+ async stepCleanup(taskId, webUrl) {
428
+ step(6, this.cleanup ? 'Cleanup' : 'Cleanup (skipped)');
429
+ if (!this.cleanup) {
430
+ info('The test task stays, so it can be reviewed.');
431
+ if (webUrl)
432
+ info(`→ ${webUrl}`);
433
+ info('Pass --cleanup to delete it automatically.');
434
+ this.results.cleanup = 'skip';
435
+ return;
436
+ }
437
+ const res = await fetch(`${this.base}/api/tasks/${taskId}`, {
438
+ method: 'DELETE',
439
+ headers: { Authorization: `Bearer ${this.env.KOLIBRI_TOKEN ?? ''}` },
440
+ signal: AbortSignal.timeout(20_000),
441
+ });
442
+ if (res.ok) {
443
+ ok(`Test task ${taskId} deleted`);
444
+ // Honest about what deletion does and does not do: Kolibri's storage is
445
+ // content-addressed and shared, so removing the task unlinks the attachment but
446
+ // leaves the blob. Harmless for a test image; worth knowing before someone assumes
447
+ // --cleanup erases an accidentally-uploaded screenshot.
448
+ info('Note: the blob stays in the content-addressed store; only the link is removed.');
449
+ this.results.cleanup = 'pass';
450
+ }
451
+ else {
452
+ warn(`Test task ${taskId} could not be deleted (HTTP ${res.status}) — please delete it by hand`);
453
+ if (webUrl)
454
+ info(`→ ${webUrl}`);
455
+ this.results.cleanup = 'fail';
456
+ }
457
+ }
458
+ /** The summary, and the exit code: 0 only when creation AND upload passed. */
459
+ finish() {
460
+ const label = {
461
+ auth: '1. Token valid',
462
+ project: '2. Workspace + project confirmed',
463
+ create: '3. Task creation',
464
+ upload: '4. File upload',
465
+ retrieve: '5. Attachment stored',
466
+ cleanup: '6. Cleaned up',
467
+ };
468
+ const mark = { pass: '\x1b[32m✓\x1b[0m', fail: '\x1b[31m✗\x1b[0m', skip: '\x1b[33m–\x1b[0m' };
469
+ console.log('\n' + '─'.repeat(60));
470
+ console.log('Result');
471
+ for (const key of Object.keys(label)) {
472
+ const r = this.results[key];
473
+ console.log(` ${r ? mark[r] : '\x1b[90m·\x1b[0m'} ${label[key]}`);
474
+ }
475
+ // The verdict is deliberately narrow: only steps 3 and 4 decide whether delivery
476
+ // works. Step 5's retrieval and step 6's cleanup are diagnostics.
477
+ const goLive = this.results.create === 'pass' && this.results.upload === 'pass';
478
+ console.log('');
479
+ if (goLive) {
480
+ console.log('\x1b[32mReady for production\x1b[0m — FEEDBACK_TARGET=kolibri can be set');
481
+ console.log('ONCE the project and the audience from step 2 are confirmed.');
482
+ }
483
+ else {
484
+ console.log('\x1b[31mDo NOT go live yet\x1b[0m — see the failed steps above.');
485
+ }
486
+ console.log('─'.repeat(60));
487
+ return goLive ? 0 : 1;
488
+ }
489
+ async run() {
490
+ const choice = (this.env.FEEDBACK_TARGET ?? '').trim().toLowerCase();
491
+ if (choice !== 'kolibri') {
492
+ console.error(`\n\x1b[31mFEEDBACK_TARGET is "${choice}", not "kolibri" — nothing to check.\x1b[0m`);
493
+ return 1;
494
+ }
495
+ const read = kolibriConfigFromEnv(this.env);
496
+ if (!read.ok) {
497
+ console.error(`\n\x1b[31mConfiguration incomplete:\x1b[0m ${read.reason}`);
498
+ return 1;
499
+ }
500
+ if (!this.imagePath || !existsSync(this.imagePath)) {
501
+ console.error(`\n\x1b[31mTest image not found\x1b[0m${this.imagePath ? ` at ${this.imagePath}` : ''} — pass one with --image <path>.`);
502
+ return 1;
503
+ }
504
+ console.log('Kolibri preflight');
505
+ console.log(` Instance: ${this.base}`);
506
+ console.log(` Workspace: ${this.ws}`);
507
+ console.log(` Project: ${this.env.KOLIBRI_PROJECT_ID ?? ''}`);
508
+ if (!(await this.stepSession()))
509
+ return this.finish();
510
+ if (!(await this.stepWorkspaceAndProject()))
511
+ return this.finish();
512
+ const outcome = await this.stepCreateAndUpload(new KolibriFeedbackTarget(read.config), randomUUID());
513
+ if (!outcome.taskId)
514
+ return this.finish();
515
+ if (outcome.uploaded) {
516
+ await this.stepRetrieve(outcome.taskId, readFileSync(this.imagePath).byteLength);
517
+ }
518
+ else {
519
+ step(5, 'Attachment stored and retrievable? (skipped)');
520
+ info('No attachment was stored — see step 4.');
521
+ this.results.retrieve = 'skip';
522
+ }
523
+ await this.stepCleanup(outcome.taskId, outcome.webUrl);
524
+ return this.finish();
525
+ }
526
+ }
527
+ /**
528
+ * Run the six checks against the instance the environment names, printing as it goes.
529
+ * Resolves with the exit code: 0 when task creation and upload both passed.
530
+ */
531
+ export function runPreflight(env, args) {
532
+ return new Preflight(env, args).run();
533
+ }