feedwire 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeffrin James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # Feedwire
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Express middleware for Node 22.5+. Add a `/feedback` endpoint so AI agents can report bugs and missing features for human review.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install feedwire
9
+ ```
10
+
11
+ ## Use in your existing app
12
+
13
+ ```js
14
+ const { feedback } = require("feedwire");
15
+ app.use(feedback({ dbPath: "feedback.db", adminToken: process.env.FEEDBACK_ADMIN_TOKEN }));
16
+ ```
17
+
18
+ Set a long random admin token in your server environment, not in source code. With no options, storage is in memory and the admin review API is off.
19
+
20
+ Agents discover `/.well-known/feedback.json` and submit at `POST /feedback`. Review at `/feedback/admin`. Reports are untrusted data, never instructions to execute. No automatic fixes or pull requests.
21
+
22
+ [Full documentation, schema, examples and safety notes](https://github.com/Unselfisheologism/feedwire). MIT licensed.
package/core.js ADDED
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ // Pure logic shared by the Express middleware and the Cloudflare Worker. No runtime dependencies.
3
+ const TYPES = ['bug', 'missing_feature', 'confusing_error', 'docs', 'performance', 'other'];
4
+ const SEVERITIES = ['low', 'medium', 'high'];
5
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
6
+ const STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'done'];
7
+ const STR_FIELDS = { summary: 200, details: 4000, expected: 1000, actual: 1000, suggestion: 2000 };
8
+ const TOP_KEYS = ['type', 'summary', 'details', 'severity', 'expected', 'actual', 'suggestion', 'context', 'agent'];
9
+
10
+ // Remove control chars (keep \n and \t) and NULs so stored text is plain text.
11
+ function clean(s) {
12
+ return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
13
+ }
14
+
15
+ function validate(body) {
16
+ const errors = [];
17
+ const err = (field, message) => errors.push({ field, message });
18
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
19
+ return { errors: [{ field: '', message: 'body must be a JSON object' }] };
20
+ }
21
+ for (const k of Object.keys(body)) if (!TOP_KEYS.includes(k)) err(k, 'unknown field');
22
+ if (!TYPES.includes(body.type)) err('type', 'must be one of: ' + TYPES.join(', '));
23
+ const out = { type: body.type };
24
+ for (const [f, max] of Object.entries(STR_FIELDS)) {
25
+ const v = body[f];
26
+ if (v === undefined) {
27
+ if (f === 'summary') err(f, 'required');
28
+ continue;
29
+ }
30
+ if (typeof v !== 'string') { err(f, 'must be a string'); continue; }
31
+ const c = clean(v).trim();
32
+ if (f === 'summary' && c.length === 0) err(f, 'must not be empty');
33
+ else if (c.length > max) err(f, `must be at most ${max} characters`);
34
+ else out[f] = c;
35
+ }
36
+ if (body.severity === undefined) out.severity = 'low';
37
+ else if (!SEVERITIES.includes(body.severity)) err('severity', 'must be one of: ' + SEVERITIES.join(', '));
38
+ else out.severity = body.severity;
39
+ if (body.context !== undefined) {
40
+ const c = body.context;
41
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) err('context', 'must be an object');
42
+ else {
43
+ const oc = {};
44
+ for (const k of Object.keys(c)) if (!['method', 'path', 'status_code', 'error_message'].includes(k)) err('context.' + k, 'unknown field');
45
+ if (c.method !== undefined) { if (!METHODS.includes(c.method)) err('context.method', 'must be one of: ' + METHODS.join(', ')); else oc.method = c.method; }
46
+ if (c.path !== undefined) { if (typeof c.path !== 'string' || c.path.length > 300) err('context.path', 'must be a string of at most 300 characters'); else oc.path = clean(c.path); }
47
+ if (c.status_code !== undefined) { if (!Number.isInteger(c.status_code) || c.status_code < 100 || c.status_code > 599) err('context.status_code', 'must be an integer from 100 to 599'); else oc.status_code = c.status_code; }
48
+ if (c.error_message !== undefined) { if (typeof c.error_message !== 'string' || c.error_message.length > 1000) err('context.error_message', 'must be a string of at most 1000 characters'); else oc.error_message = clean(c.error_message); }
49
+ out.context = oc;
50
+ }
51
+ }
52
+ if (body.agent !== undefined) {
53
+ const a = body.agent;
54
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) err('agent', 'must be an object');
55
+ else {
56
+ const oa = {};
57
+ for (const k of Object.keys(a)) if (!['name', 'version'].includes(k)) err('agent.' + k, 'unknown field');
58
+ if (a.name !== undefined) { if (typeof a.name !== 'string' || a.name.length > 100) err('agent.name', 'must be a string of at most 100 characters'); else oa.name = clean(a.name); }
59
+ if (a.version !== undefined) { if (typeof a.version !== 'string' || a.version.length > 50) err('agent.version', 'must be a string of at most 50 characters'); else oa.version = clean(a.version); }
60
+ out.agent = oa;
61
+ }
62
+ }
63
+ return errors.length ? { errors } : { value: out };
64
+ }
65
+
66
+ const top = (m, n = 10) => [...m.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).slice(0, n).map(([key, count]) => ({ key, count }));
67
+ const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
68
+
69
+ // What are agents struggling with? Aggregates over rows from the last `days` days.
70
+ function stats(rows, days) {
71
+ const by = { type: new Map(), severity: new Map(), status: new Map(), endpoint: new Map(), agent: new Map(), day: new Map() };
72
+ const struggles = new Map();
73
+ for (const r of rows) {
74
+ bump(by.type, r.type); bump(by.severity, r.severity); bump(by.status, r.status);
75
+ bump(by.day, r.created_at.slice(0, 10));
76
+ const c = r.payload.context;
77
+ if (c && c.path) {
78
+ const ep = (c.method ? c.method + ' ' : '') + c.path;
79
+ bump(by.endpoint, ep);
80
+ bump(struggles, ep + ' | ' + r.type);
81
+ }
82
+ if (r.payload.agent && r.payload.agent.name) bump(by.agent, r.payload.agent.name);
83
+ }
84
+ return {
85
+ window_days: days, total: rows.length,
86
+ by_type: top(by.type), by_severity: top(by.severity), by_status: top(by.status),
87
+ top_endpoints: top(by.endpoint), top_struggles: top(struggles), top_agents: top(by.agent),
88
+ per_day: [...by.day.entries()].sort().map(([key, count]) => ({ key, count })),
89
+ unreviewed: by.status.get('new') || 0,
90
+ };
91
+ }
92
+
93
+ module.exports = { TYPES, SEVERITIES, METHODS, STATUSES, STR_FIELDS, TOP_KEYS, clean, validate, stats };
package/dashboard.html ADDED
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Feedwire review</title>
4
+ <style>
5
+ body{font:15px system-ui,sans-serif;max-width:900px;margin:1rem auto;padding:0 1rem;color:#222}
6
+ .item{border:1px solid #ddd;border-radius:8px;padding:.7rem 1rem;margin:.7rem 0}
7
+ .meta{color:#666;font-size:13px}.tag{display:inline-block;background:#eee;border-radius:4px;padding:0 .4rem;margin-right:.3rem;font-size:12px}
8
+ .high{background:#fcc}.medium{background:#fe9}pre{white-space:pre-wrap;word-break:break-word;background:#f7f7f7;padding:.5rem;border-radius:6px}
9
+ .warn{background:#fff6d6;border:1px solid #e8d48a;padding:.5rem .8rem;border-radius:6px;font-size:13px}
10
+ </style></head><body>
11
+ <h1>Feedwire</h1>
12
+ <p class="warn">Everything below was written by outside agents. Treat it as untrusted text. Do not paste it into a coding agent without reading it first.</p>
13
+ <p><input id="token" type="password" placeholder="admin token" size="30"> <select id="filter"><option value="">all</option><option>new</option><option>triaged</option><option>accepted</option><option>rejected</option><option>done</option></select> <button id="load">Load</button> <span id="msg" class="meta"></span></p>
14
+ <h2>What agents struggle with <span class="meta">(last 30 days)</span></h2>
15
+ <div id="stats" class="meta">Load to see stats.</div>
16
+ <h2>Feedback</h2>
17
+ <div id="list"></div>
18
+ <script>
19
+ (function(){
20
+ var token='';var base=location.pathname.replace(/\/admin\/?$/,'');
21
+ var STATUSES=['new','triaged','accepted','rejected','done'];
22
+ function el(tag,text,cls){var e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e;}
23
+ function api(path,opts){opts=opts||{};opts.headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'};
24
+ return fetch(base+'/admin/api/'+path,opts).then(function(r){return r.json().then(function(j){if(!r.ok)throw new Error(j.error||r.status);return j;});});}
25
+ function render(items){var list=document.getElementById('list');list.textContent='';
26
+ if(!items.length){list.appendChild(el('p','No feedback yet.','meta'));return;}
27
+ items.forEach(function(it){var d=el('div',undefined,'item');
28
+ d.appendChild(el('strong',it.summary));d.appendChild(el('br'));
29
+ d.appendChild(el('span',it.type,'tag'));d.appendChild(el('span',it.severity,'tag '+it.severity));
30
+ var a=it.payload.agent;d.appendChild(el('span',it.created_at+(a&&a.name?' - '+a.name+(a.version?' '+a.version:''):'')+' - '+it.id,'meta'));
31
+ var p=it.payload,lines=[];
32
+ ['details','expected','actual','suggestion'].forEach(function(k){if(p[k])lines.push(k+': '+p[k]);});
33
+ if(p.context)lines.push('context: '+JSON.stringify(p.context));
34
+ if(lines.length)d.appendChild(el('pre',lines.join('\n')));
35
+ var sel=el('select');STATUSES.forEach(function(s){var o=el('option',s);if(s===it.status)o.selected=true;sel.appendChild(o);});
36
+ var note=el('input');note.value=it.note||'';note.placeholder='note';note.size=30;
37
+ var save=el('button','Save');save.onclick=function(){api('items/'+encodeURIComponent(it.id),{method:'PATCH',body:JSON.stringify({status:sel.value,note:note.value})}).then(function(){msg('Saved');}).catch(function(e){msg(e.message);});};
38
+ d.appendChild(sel);d.appendChild(document.createTextNode(' '));d.appendChild(note);d.appendChild(document.createTextNode(' '));d.appendChild(save);
39
+ list.appendChild(d);});}
40
+ function bars(title,arr){var d=el('div');d.appendChild(el('strong',title));
41
+ if(!arr.length){d.appendChild(el('div','none','meta'));return d;}
42
+ var max=arr[0].count;arr.forEach(function(x){var r=el('div');var b=el('span','','bar');b.style.cssText='display:inline-block;height:10px;background:#79c;margin-right:6px;width:'+Math.max(4,Math.round(120*x.count/max))+'px';r.appendChild(b);r.appendChild(document.createTextNode(x.count+' '+x.key));d.appendChild(r);});return d;}
43
+ function renderStats(s){var box=document.getElementById('stats');box.textContent='';
44
+ box.appendChild(el('p',s.total+' reports, '+s.unreviewed+' not yet reviewed'));
45
+ var g=el('div');g.style.cssText='display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem';
46
+ g.appendChild(bars('Top struggles (endpoint | type)',s.top_struggles));g.appendChild(bars('By type',s.by_type));
47
+ g.appendChild(bars('Top endpoints',s.top_endpoints));g.appendChild(bars('By severity',s.by_severity));
48
+ g.appendChild(bars('Top agents',s.top_agents));g.appendChild(bars('Per day',s.per_day));box.appendChild(g);}
49
+ function msg(t){document.getElementById('msg').textContent=t;}
50
+ document.getElementById('load').onclick=function(){token=document.getElementById('token').value.trim();var f=document.getElementById('filter').value;
51
+ api('stats').then(renderStats).catch(function(e){msg(e.message);});
52
+ api('items'+(f?'?status='+f:'')).then(function(j){msg(j.items.length+' items');render(j.items);}).catch(function(e){msg(e.message);});};
53
+ })();
54
+ </script></body></html>
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Feedwire submission v1",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["type", "summary"],
7
+ "properties": {
8
+ "type": { "enum": ["bug", "missing_feature", "confusing_error", "docs", "performance", "other"] },
9
+ "summary": { "type": "string", "minLength": 1, "maxLength": 200 },
10
+ "details": { "type": "string", "maxLength": 4000 },
11
+ "severity": { "enum": ["low", "medium", "high"], "default": "low" },
12
+ "expected": { "type": "string", "maxLength": 1000 },
13
+ "actual": { "type": "string", "maxLength": 1000 },
14
+ "suggestion": { "type": "string", "maxLength": 2000 },
15
+ "context": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "properties": {
19
+ "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] },
20
+ "path": { "type": "string", "maxLength": 300 },
21
+ "status_code": { "type": "integer", "minimum": 100, "maximum": 599 },
22
+ "error_message": { "type": "string", "maxLength": 1000 }
23
+ }
24
+ },
25
+ "agent": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "name": { "type": "string", "maxLength": 100 },
30
+ "version": { "type": "string", "maxLength": 50 }
31
+ }
32
+ }
33
+ }
34
+ }
package/index.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ // Feedwire middleware for Express (or any connect-style server).
3
+ // Storage: node:sqlite (built into Node >= 22.5). No runtime dependencies.
4
+ const { DatabaseSync } = require('node:sqlite');
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+
9
+ const { STATUSES, clean, validate, stats } = require('./core');
10
+ const SCHEMA = JSON.parse(fs.readFileSync(path.join(__dirname, 'feedback.schema.json'), 'utf8'));
11
+ const DASHBOARD = fs.readFileSync(path.join(__dirname, 'dashboard.html'), 'utf8');
12
+
13
+ function send(res, status, obj, headers) {
14
+ const body = JSON.stringify(obj);
15
+ res.writeHead(status, Object.assign({
16
+ 'Content-Type': 'application/json; charset=utf-8',
17
+ 'Content-Length': Buffer.byteLength(body),
18
+ 'Cache-Control': 'no-store',
19
+ 'X-Content-Type-Options': 'nosniff',
20
+ }, headers));
21
+ res.end(body);
22
+ }
23
+
24
+ function readBody(req, limit) {
25
+ return new Promise((resolve, reject) => {
26
+ const declared = Number(req.headers['content-length']);
27
+ if (declared > limit) return reject(Object.assign(new Error('too large'), { code: 'TOO_LARGE' }));
28
+ const chunks = [];
29
+ let size = 0;
30
+ req.on('data', (c) => {
31
+ size += c.length;
32
+ if (size > limit) { req.destroy(); return reject(Object.assign(new Error('too large'), { code: 'TOO_LARGE' })); }
33
+ chunks.push(c);
34
+ });
35
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
36
+ req.on('error', reject);
37
+ });
38
+ }
39
+
40
+ function safeEqual(a, b) {
41
+ const ha = crypto.createHash('sha256').update(String(a)).digest();
42
+ const hb = crypto.createHash('sha256').update(String(b)).digest();
43
+ return crypto.timingSafeEqual(ha, hb);
44
+ }
45
+
46
+ /**
47
+ * @param {object} opts
48
+ * @param {string} [opts.dbPath=':memory:'] SQLite file path
49
+ * @param {string} [opts.adminToken] Bearer token for the review API. Unset = review API disabled.
50
+ * @param {string} [opts.basePath='/feedback']
51
+ * @param {number} [opts.maxBodyBytes=16384]
52
+ * @param {number} [opts.rateLimitPerMinute=30] per client IP
53
+ * @param {number} [opts.maxRows=50000] new submissions are refused (503) past this
54
+ * @param {boolean} [opts.trustProxy=false] use X-Forwarded-For for rate limiting
55
+ */
56
+ function feedback(opts = {}) {
57
+ const base = (opts.basePath || '/feedback').replace(/\/+$/, '');
58
+ const maxBody = opts.maxBodyBytes || 16384;
59
+ const perMin = opts.rateLimitPerMinute || 30;
60
+ const maxRows = opts.maxRows || 50000;
61
+ const db = new DatabaseSync(opts.dbPath || ':memory:');
62
+ db.exec(`CREATE TABLE IF NOT EXISTS feedback (
63
+ id TEXT PRIMARY KEY,
64
+ created_at TEXT NOT NULL,
65
+ status TEXT NOT NULL DEFAULT 'new',
66
+ type TEXT NOT NULL,
67
+ severity TEXT NOT NULL,
68
+ summary TEXT NOT NULL,
69
+ payload TEXT NOT NULL,
70
+ note TEXT NOT NULL DEFAULT ''
71
+ )`);
72
+ const insert = db.prepare('INSERT INTO feedback (id, created_at, status, type, severity, summary, payload) VALUES (?,?,?,?,?,?,?)');
73
+ const count = db.prepare('SELECT COUNT(*) AS n FROM feedback');
74
+ const windows = new Map();
75
+
76
+ function limited(ip) {
77
+ const now = Date.now();
78
+ if (windows.size > 10000) for (const [k, v] of windows) if (now - v.start > 60000) windows.delete(k);
79
+ let w = windows.get(ip);
80
+ if (!w || now - w.start > 60000) { w = { start: now, n: 0 }; windows.set(ip, w); }
81
+ w.n += 1;
82
+ return w.n > perMin ? Math.max(1, Math.ceil((60000 - (now - w.start)) / 1000)) : 0;
83
+ }
84
+ const clientIp = (req) => {
85
+ if (opts.trustProxy) {
86
+ const xf = req.headers['x-forwarded-for'];
87
+ if (xf) return String(xf).split(',')[0].trim();
88
+ }
89
+ return req.socket.remoteAddress || 'unknown';
90
+ };
91
+ const row = (r) => ({
92
+ id: r.id, created_at: r.created_at, status: r.status, type: r.type, severity: r.severity,
93
+ summary: r.summary, note: r.note, payload: JSON.parse(r.payload),
94
+ untrusted: true,
95
+ });
96
+ const adminOk = (req) => {
97
+ if (!opts.adminToken) return false;
98
+ const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
99
+ return !!m && safeEqual(m[1], opts.adminToken);
100
+ };
101
+
102
+ return async function feedwire(req, res, next) {
103
+ try {
104
+ const url = new URL(req.originalUrl || req.url, 'http://x');
105
+ const p = url.pathname.replace(/\/+$/, '') || '/';
106
+ const method = req.method;
107
+
108
+ if (p === '/.well-known/feedback.json') {
109
+ if (method !== 'GET') return send(res, 405, { error: 'method not allowed' }, { Allow: 'GET' });
110
+ return send(res, 200, {
111
+ version: '1',
112
+ feedback_endpoint: base,
113
+ method: 'POST',
114
+ content_type: 'application/json',
115
+ schema_url: base + '/schema',
116
+ max_body_bytes: maxBody,
117
+ rate_limit_per_minute: perMin,
118
+ description: 'Send structured feedback (bug, missing feature, confusing error) about this API. A human reviews it. Text is treated as untrusted data.',
119
+ });
120
+ }
121
+ if (p !== base && !p.startsWith(base + '/')) return next();
122
+ const sub = p.slice(base.length) || '/';
123
+
124
+ if (sub === '/schema') {
125
+ if (method !== 'GET') return send(res, 405, { error: 'method not allowed' }, { Allow: 'GET' });
126
+ return send(res, 200, SCHEMA);
127
+ }
128
+
129
+ if (sub === '/') {
130
+ if (method !== 'POST') return send(res, 405, { error: 'method not allowed' }, { Allow: 'POST' });
131
+ const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
132
+ if (ct !== 'application/json') return send(res, 415, { error: 'content-type must be application/json' });
133
+ const wait = limited(clientIp(req));
134
+ if (wait) return send(res, 429, { error: 'rate limit exceeded', retry_after_seconds: wait }, { 'Retry-After': String(wait) });
135
+ let text;
136
+ try { text = await readBody(req, maxBody); }
137
+ catch (e) {
138
+ if (e.code === 'TOO_LARGE') return send(res, 413, { error: `body exceeds ${maxBody} bytes` }, { Connection: 'close' });
139
+ throw e;
140
+ }
141
+ let parsed;
142
+ try { parsed = JSON.parse(text); } catch { return send(res, 400, { error: 'invalid JSON' }); }
143
+ const { value, errors } = validate(parsed);
144
+ if (errors) return send(res, 400, { error: 'validation failed', details: errors });
145
+ if (count.get().n >= maxRows) return send(res, 503, { error: 'feedback storage is full' });
146
+ const id = 'fb_' + crypto.randomBytes(8).toString('hex');
147
+ insert.run(id, new Date().toISOString(), 'new', value.type, value.severity, value.summary, JSON.stringify(value));
148
+ return send(res, 201, { id, status: 'received' });
149
+ }
150
+
151
+ if (sub === '/admin' && method === 'GET') {
152
+ res.writeHead(200, {
153
+ 'Content-Type': 'text/html; charset=utf-8',
154
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
155
+ 'X-Content-Type-Options': 'nosniff',
156
+ 'Cache-Control': 'no-store',
157
+ });
158
+ return res.end(DASHBOARD);
159
+ }
160
+
161
+ if (sub === '/admin/api/stats') {
162
+ if (!adminOk(req)) return send(res, 401, { error: 'admin token required' }, { 'WWW-Authenticate': 'Bearer' });
163
+ if (method !== 'GET') return send(res, 405, { error: 'method not allowed' }, { Allow: 'GET' });
164
+ const days = Math.min(Math.max(parseInt(url.searchParams.get('days') || '30', 10) || 30, 1), 365);
165
+ const since = new Date(Date.now() - days * 86400000).toISOString();
166
+ const rows = db.prepare('SELECT created_at, status, type, severity, payload FROM feedback WHERE created_at >= ?').all(since);
167
+ return send(res, 200, stats(rows.map((r) => Object.assign({}, r, { payload: JSON.parse(r.payload) })), days));
168
+ }
169
+
170
+ if (sub === '/admin/api/items' || sub.startsWith('/admin/api/items/')) {
171
+ if (!adminOk(req)) return send(res, 401, { error: 'admin token required' }, { 'WWW-Authenticate': 'Bearer' });
172
+ if (sub === '/admin/api/items') {
173
+ if (method !== 'GET') return send(res, 405, { error: 'method not allowed' }, { Allow: 'GET' });
174
+ const status = url.searchParams.get('status');
175
+ if (status && !STATUSES.includes(status)) return send(res, 400, { error: 'bad status filter' });
176
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 500);
177
+ const rows = status
178
+ ? db.prepare('SELECT * FROM feedback WHERE status = ? ORDER BY created_at DESC, rowid DESC LIMIT ?').all(status, limit)
179
+ : db.prepare('SELECT * FROM feedback ORDER BY created_at DESC, rowid DESC LIMIT ?').all(limit);
180
+ return send(res, 200, { items: rows.map(row) });
181
+ }
182
+ const id = decodeURIComponent(sub.slice('/admin/api/items/'.length));
183
+ const r = db.prepare('SELECT * FROM feedback WHERE id = ?').get(id);
184
+ if (!r) return send(res, 404, { error: 'not found' });
185
+ if (method === 'GET') return send(res, 200, row(r));
186
+ if (method === 'PATCH') {
187
+ let b;
188
+ try { b = JSON.parse(await readBody(req, maxBody)); } catch { return send(res, 400, { error: 'invalid JSON' }); }
189
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return send(res, 400, { error: 'body must be an object' });
190
+ if (b.status !== undefined && !STATUSES.includes(b.status)) return send(res, 400, { error: 'status must be one of: ' + STATUSES.join(', ') });
191
+ if (b.note !== undefined && (typeof b.note !== 'string' || b.note.length > 2000)) return send(res, 400, { error: 'note must be a string of at most 2000 characters' });
192
+ db.prepare('UPDATE feedback SET status = ?, note = ? WHERE id = ?').run(b.status ?? r.status, b.note !== undefined ? clean(b.note) : r.note, id);
193
+ return send(res, 200, row(db.prepare('SELECT * FROM feedback WHERE id = ?').get(id)));
194
+ }
195
+ return send(res, 405, { error: 'method not allowed' }, { Allow: 'GET, PATCH' });
196
+ }
197
+ return send(res, 404, { error: 'not found' });
198
+ } catch (e) {
199
+ if (typeof next === 'function' && !res.headersSent) return next(e);
200
+ if (!res.headersSent) send(res, 500, { error: 'internal error' });
201
+ }
202
+ };
203
+ }
204
+
205
+ module.exports = { feedback, validate, STATUSES };
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "feedwire",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A /feedback endpoint for your API so AI agents can report bugs and missing features. Express middleware.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "core.js",
9
+ "dashboard.html",
10
+ "feedback.schema.json",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.5"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test --no-warnings test/*.test.js"
19
+ },
20
+ "license": "MIT",
21
+ "devDependencies": {
22
+ "express": "^4.21.2"
23
+ },
24
+ "homepage": "https://github.com/Unselfisheologism/feedwire#readme",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/Unselfisheologism/feedwire.git",
28
+ "directory": "node"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/Unselfisheologism/feedwire/issues"
32
+ },
33
+ "keywords": [
34
+ "feedback",
35
+ "ai",
36
+ "agents",
37
+ "express",
38
+ "api",
39
+ "middleware"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }