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