papercliped 1.1.0-beta.2 → 2.2.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/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +70 -1
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/commands/paperclip-report.md +5 -5
- package/commands/paperclip-status.md +4 -4
- package/commands/paperclip-sync.md +5 -5
- package/dist/accounts/alias.js +1 -1
- package/dist/accounts/username.js +1 -1
- package/dist/cli.js +11 -11
- package/dist/http.js +17 -3
- package/dist/manage/routes.js +60 -82
- package/dist/oauth/pages.js +32 -18
- package/dist/oauth/paperclip-login.js +38 -0
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +83 -64
- package/dist/oauth/scopes.js +19 -16
- package/dist/openapi.js +19 -2
- package/dist/public-api/health.js +46 -0
- package/dist/public-api/openapi.js +51 -0
- package/dist/public-api/routes.js +251 -0
- package/dist/public-api/types.js +14 -0
- package/dist/server.js +4 -0
- package/dist/site/landing.js +3 -3
- package/dist/site/markdown.js +2 -1
- package/dist/site/site.js +16 -4
- package/dist/telemetry/recorder.js +1 -1
- package/dist/tools.js +48 -2
- package/dist/web/csp.js +16 -0
- package/dist/web/hosts.js +23 -0
- package/dist/web/routes.js +132 -0
- package/docs/ARCHITECTURE.md +74 -73
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -3
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +50 -34
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -65
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -20
- package/docs/least-privilege.sql +23 -23
- package/docs/legal/PRIVACY.md +37 -37
- package/docs/legal/TERMS.md +11 -11
- package/docs/oauth.md +61 -62
- package/docs/supabase-schema.sql +8 -1
- package/migrations/001_init.sql +84 -84
- package/migrations/002_audit.sql +41 -41
- package/migrations/003_accounts.sql +66 -66
- package/migrations/004_privacy_panel.sql +57 -57
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +10 -14
- package/site/docs/chatgpt.md +1 -1
- package/site/docs/cli.md +27 -0
- package/site/docs/connect-your-paperclip.md +120 -0
- package/site/docs/environment.md +64 -0
- package/site/docs/faq.md +31 -0
- package/site/docs/getting-started.md +11 -5
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +12 -21
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +36 -0
- package/site/docs/permissions.md +5 -5
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +2 -2
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +3 -3
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +2 -2
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +5 -3
- package/site/terms.md +1 -1
- package/skills/paperclip/SKILL.md +23 -23
- package/dist/demo.js +0 -144
- package/dist/manage/page.js +0 -170
- package/dist/panel/config.js +0 -61
- package/dist/panel/main.js +0 -90
- package/dist/panel/page.js +0 -553
- package/dist/panel/routes.js +0 -284
- package/dist/panel/server.js +0 -37
- package/docs/panel-role.sql +0 -26
package/dist/panel/routes.js
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { randomToken, safeEqual } from "../oauth/crypto.js";
|
|
3
|
-
import { loginPage, panelPage } from "./page.js";
|
|
4
|
-
/** window → [span ms, bucket ms] */
|
|
5
|
-
export const WINDOWS = {
|
|
6
|
-
"5m": [5 * 60_000, 5_000],
|
|
7
|
-
"15m": [15 * 60_000, 15_000],
|
|
8
|
-
"1h": [3_600_000, 60_000],
|
|
9
|
-
"6h": [6 * 3_600_000, 300_000],
|
|
10
|
-
"24h": [24 * 3_600_000, 900_000],
|
|
11
|
-
"7d": [7 * 24 * 3_600_000, 3_600_000],
|
|
12
|
-
};
|
|
13
|
-
/** The bridge reports every 30 s. Silence for this long means it is down, asleep (free tier), or cannot reach the database. */
|
|
14
|
-
export const BRIDGE_SILENT_MS = 120_000;
|
|
15
|
-
export function assessHealth(i) {
|
|
16
|
-
const reasons = [];
|
|
17
|
-
let level = 0;
|
|
18
|
-
const bump = (l, why) => {
|
|
19
|
-
level = Math.max(level, l);
|
|
20
|
-
reasons.push(why);
|
|
21
|
-
};
|
|
22
|
-
const faultRate = i.count ? i.faults / i.count : 0;
|
|
23
|
-
if (i.count >= 5) {
|
|
24
|
-
if (faultRate >= 0.1)
|
|
25
|
-
bump(2, `${(faultRate * 100).toFixed(1)}% of calls hit a system fault`);
|
|
26
|
-
else if (faultRate >= 0.02)
|
|
27
|
-
bump(1, `${(faultRate * 100).toFixed(1)}% of calls hit a system fault`);
|
|
28
|
-
if (i.p95 >= i.slowMs * 3)
|
|
29
|
-
bump(2, `p95 latency ${Math.round(i.p95)} ms`);
|
|
30
|
-
else if (i.p95 >= i.slowMs)
|
|
31
|
-
bump(1, `p95 latency ${Math.round(i.p95)} ms`);
|
|
32
|
-
}
|
|
33
|
-
if (i.bridgeSilentMs === null)
|
|
34
|
-
bump(1, "no bridge has reported yet");
|
|
35
|
-
else if (i.bridgeSilentMs > BRIDGE_SILENT_MS)
|
|
36
|
-
bump(2, `the bridge has not reported for ${Math.round(i.bridgeSilentMs / 1000)} s (asleep, down, or cannot reach the database)`);
|
|
37
|
-
if (i.recentDbFailed)
|
|
38
|
-
bump(2, "database ping is failing");
|
|
39
|
-
else if (i.dbPingMs != null && i.dbPingMs >= 1000)
|
|
40
|
-
bump(2, `database ping ${Math.round(i.dbPingMs)} ms`);
|
|
41
|
-
else if (i.dbPingMs != null && i.dbPingMs >= 250)
|
|
42
|
-
bump(1, `database ping ${Math.round(i.dbPingMs)} ms`);
|
|
43
|
-
if (i.loopLagP99Ms >= 500)
|
|
44
|
-
bump(2, `event-loop lag p99 ${Math.round(i.loopLagP99Ms)} ms`);
|
|
45
|
-
else if (i.loopLagP99Ms >= 100)
|
|
46
|
-
bump(1, `event-loop lag p99 ${Math.round(i.loopLagP99Ms)} ms`);
|
|
47
|
-
if (level === 0)
|
|
48
|
-
return { state: i.count === 0 ? "idle" : "healthy", reasons: i.count === 0 ? ["no calls in this window"] : [] };
|
|
49
|
-
return { state: level === 2 ? "critical" : "degraded", reasons };
|
|
50
|
-
}
|
|
51
|
-
const COOKIE = "pcp_panel";
|
|
52
|
-
export class PanelRoutes {
|
|
53
|
-
d;
|
|
54
|
-
key;
|
|
55
|
-
now;
|
|
56
|
-
grantsCache = { at: 0, v: null };
|
|
57
|
-
constructor(d) {
|
|
58
|
-
this.d = d;
|
|
59
|
-
this.key = Buffer.from(hkdfSync("sha256", d.token, "paperclip-bridge", "admin-session-v1", 32));
|
|
60
|
-
this.now = d.now ?? Date.now;
|
|
61
|
-
}
|
|
62
|
-
sign(expSec) {
|
|
63
|
-
return `${expSec}.${createHmac("sha256", this.key).update(`admin|${expSec}`).digest("base64url")}`;
|
|
64
|
-
}
|
|
65
|
-
sessionValid(req) {
|
|
66
|
-
const m = new RegExp(`(?:^|; )${COOKIE}=([^;]+)`).exec(req.headers.cookie ?? "");
|
|
67
|
-
if (!m)
|
|
68
|
-
return false;
|
|
69
|
-
const [exp, mac] = m[1].split(".");
|
|
70
|
-
const expSec = Number(exp);
|
|
71
|
-
if (!Number.isFinite(expSec) || expSec * 1000 < this.now() || !mac)
|
|
72
|
-
return false;
|
|
73
|
-
const want = Buffer.from(this.sign(expSec).split(".")[1]);
|
|
74
|
-
const got = Buffer.from(mac);
|
|
75
|
-
return want.length === got.length && timingSafeEqual(want, got);
|
|
76
|
-
}
|
|
77
|
-
ip(req) {
|
|
78
|
-
const hops = this.d.proxyHops ?? 0;
|
|
79
|
-
if (hops > 0) {
|
|
80
|
-
const parts = String(req.headers["x-forwarded-for"] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
81
|
-
if (parts.length >= hops)
|
|
82
|
-
return parts[parts.length - hops];
|
|
83
|
-
}
|
|
84
|
-
return req.socket.remoteAddress ?? "unknown";
|
|
85
|
-
}
|
|
86
|
-
send(res, status, body, type, extra = {}) {
|
|
87
|
-
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", ...extra });
|
|
88
|
-
res.end(body);
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
json = (res, status, body) => this.send(res, status, JSON.stringify(body), "application/json");
|
|
92
|
-
html(res, status, build, extra = {}) {
|
|
93
|
-
const nonce = randomToken(12);
|
|
94
|
-
return this.send(res, status, build(nonce), "text/html; charset=utf-8", {
|
|
95
|
-
"Content-Security-Policy": `default-src 'none'; img-src data:; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
|
|
96
|
-
"X-Frame-Options": "DENY",
|
|
97
|
-
...extra,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
cookie(value, maxAgeSec) {
|
|
101
|
-
return `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSec}${this.d.secureCookie ? "; Secure" : ""}`;
|
|
102
|
-
}
|
|
103
|
-
async handle(req, res, url) {
|
|
104
|
-
const p = url.pathname;
|
|
105
|
-
const m = req.method ?? "GET";
|
|
106
|
-
if (p === "/healthz")
|
|
107
|
-
return this.json(res, 200, { ok: true });
|
|
108
|
-
if (m === "POST" && p === "/login")
|
|
109
|
-
return this.login(req, res);
|
|
110
|
-
if (m === "POST" && p === "/logout")
|
|
111
|
-
return this.send(res, 303, "", "text/plain", { Location: "/", "Set-Cookie": this.cookie("", 0) });
|
|
112
|
-
const authed = this.sessionValid(req);
|
|
113
|
-
if (m === "GET" && (p === "/" || p === "/index.html")) {
|
|
114
|
-
return authed ? this.html(res, 200, (n) => panelPage(n, { slowMs: this.d.slowMs })) : this.html(res, 200, (n) => loginPage(n));
|
|
115
|
-
}
|
|
116
|
-
if (!p.startsWith("/api/"))
|
|
117
|
-
return this.json(res, 404, { error: "Not found" });
|
|
118
|
-
if (!authed)
|
|
119
|
-
return this.json(res, 401, { error: "Sign in first" });
|
|
120
|
-
if (m === "GET" && p === "/api/summary")
|
|
121
|
-
return this.summary(res, url);
|
|
122
|
-
if (m === "GET" && p === "/api/events")
|
|
123
|
-
return this.events(res, url);
|
|
124
|
-
if (m === "GET" && p === "/api/community/events")
|
|
125
|
-
return this.communityEvents(res, url);
|
|
126
|
-
return this.json(res, 404, { error: "Not found" });
|
|
127
|
-
}
|
|
128
|
-
async login(req, res) {
|
|
129
|
-
if (!this.d.limit(`panel-login:${this.ip(req)}`, 5, 60_000)) {
|
|
130
|
-
return this.html(res, 429, (n) => loginPage(n, "Too many attempts. Wait a minute."));
|
|
131
|
-
}
|
|
132
|
-
let body = "";
|
|
133
|
-
for await (const c of req) {
|
|
134
|
-
body += c;
|
|
135
|
-
if (body.length > 4096)
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
const token = new URLSearchParams(body).get("token") ?? "";
|
|
139
|
-
if (!safeEqual(token, this.d.token))
|
|
140
|
-
return this.html(res, 401, (n) => loginPage(n, "Incorrect token."));
|
|
141
|
-
const maxAge = this.d.sessionHours * 3600;
|
|
142
|
-
const expSec = Math.floor(this.now() / 1000) + maxAge;
|
|
143
|
-
return this.send(res, 303, "", "text/plain", { Location: "/", "Set-Cookie": this.cookie(this.sign(expSec), maxAge) });
|
|
144
|
-
}
|
|
145
|
-
async grants() {
|
|
146
|
-
if (this.now() - this.grantsCache.at > 10_000) {
|
|
147
|
-
this.grantsCache = { at: this.now(), v: await this.d.store.liveGrantCount().catch(() => null) };
|
|
148
|
-
}
|
|
149
|
-
return this.grantsCache.v;
|
|
150
|
-
}
|
|
151
|
-
async summary(res, url) {
|
|
152
|
-
const win = url.searchParams.get("window") ?? "15m";
|
|
153
|
-
const spec = WINDOWS[win];
|
|
154
|
-
if (!spec)
|
|
155
|
-
return this.json(res, 400, { error: `window must be one of ${Object.keys(WINDOWS).join(", ")}` });
|
|
156
|
-
const [span, bucketMs] = spec;
|
|
157
|
-
const to = this.now() + 1; // include events stamped in this very millisecond
|
|
158
|
-
const from = to - span;
|
|
159
|
-
const S = this.d.store;
|
|
160
|
-
try {
|
|
161
|
-
const [totals, prev, series, histogram, byTool, byInstance, byError, slowest, httpTotals, httpByRoute, httpSeries, live, byUser, userCounts, userSeries, activeUsers, totalUsers] = await Promise.all([
|
|
162
|
-
S.auditTotals("tool", from, to),
|
|
163
|
-
S.auditTotals("tool", from - span, from),
|
|
164
|
-
S.auditSeries("tool", from, to, bucketMs),
|
|
165
|
-
S.auditHistogram("tool", from, to),
|
|
166
|
-
S.auditBreakdown("tool", "name", from, to, 30),
|
|
167
|
-
S.auditBreakdown("tool", "instance", from, to, 50),
|
|
168
|
-
S.auditBreakdown("tool", "errorClass", from, to, 20),
|
|
169
|
-
S.auditSlowest("tool", from, to, 8),
|
|
170
|
-
S.auditTotals("http", from, to),
|
|
171
|
-
S.auditBreakdown("http", "name", from, to, 20),
|
|
172
|
-
S.auditSeries("http", from, to, bucketMs),
|
|
173
|
-
this.grants(),
|
|
174
|
-
S.auditBreakdown("tool", "username", from, to, 50),
|
|
175
|
-
S.userEventCounts(from, to),
|
|
176
|
-
S.userEventSeries(from, to, bucketMs),
|
|
177
|
-
S.auditActiveUsers(from, to),
|
|
178
|
-
S.countAccounts(),
|
|
179
|
-
]);
|
|
180
|
-
const started = userCounts.byKind.started ?? 0;
|
|
181
|
-
const completed = userCounts.byKind.completed ?? 0;
|
|
182
|
-
const nowMs = this.now();
|
|
183
|
-
const samples = await this.d.store.nodeSamples(nowMs - 15 * 60_000);
|
|
184
|
-
const nodes = latestPerNode(samples, nowMs);
|
|
185
|
-
const newest = nodes.length ? Math.min(...nodes.map((n) => n.ageMs)) : null;
|
|
186
|
-
const pings = samples.filter((x) => x.dbPingMs != null).slice(0, 12).map((x) => x.dbPingMs).reverse();
|
|
187
|
-
const lead = nodes[0];
|
|
188
|
-
const health = assessHealth({
|
|
189
|
-
count: totals.count,
|
|
190
|
-
faults: totals.faults,
|
|
191
|
-
p95: totals.p95,
|
|
192
|
-
dbPingMs: lead?.dbPingMs ?? null,
|
|
193
|
-
loopLagP99Ms: Math.max(0, ...nodes.filter((n) => n.ageMs <= BRIDGE_SILENT_MS).map((n) => n.loopLagP99Ms)),
|
|
194
|
-
slowMs: this.d.slowMs,
|
|
195
|
-
recentDbFailed: samples.slice(0, 3).length > 0 && samples.slice(0, 3).every((x) => x.dbPingMs == null),
|
|
196
|
-
bridgeSilentMs: newest,
|
|
197
|
-
});
|
|
198
|
-
return this.json(res, 200, {
|
|
199
|
-
window: win,
|
|
200
|
-
from,
|
|
201
|
-
to,
|
|
202
|
-
bucketMs,
|
|
203
|
-
slowMs: this.d.slowMs,
|
|
204
|
-
generatedAt: this.now(),
|
|
205
|
-
health,
|
|
206
|
-
totals,
|
|
207
|
-
previous: prev,
|
|
208
|
-
series,
|
|
209
|
-
histogram,
|
|
210
|
-
byTool,
|
|
211
|
-
byInstance,
|
|
212
|
-
byError,
|
|
213
|
-
slowest: slowest.map(rowJson),
|
|
214
|
-
http: { totals: httpTotals, byRoute: httpByRoute, series: httpSeries },
|
|
215
|
-
community: {
|
|
216
|
-
name: this.d.communityName ?? "cliped",
|
|
217
|
-
totalUsers,
|
|
218
|
-
activeUsers,
|
|
219
|
-
counts: userCounts.byKind,
|
|
220
|
-
failures: userCounts.failures,
|
|
221
|
-
series: userSeries,
|
|
222
|
-
byUser,
|
|
223
|
-
// Flows that were started but never finished (abandoned) count against the rate: that is what a visitor experienced.
|
|
224
|
-
successRate: started > 0 ? Math.min(1, completed / started) : null,
|
|
225
|
-
},
|
|
226
|
-
system: { nodes, bridgeSilentMs: newest, bridgeSilentAfterMs: BRIDGE_SILENT_MS, recentPing: pings, mode: this.d.mode, persistent: this.d.persistent, liveGrants: live, panelPingMs: this.d.panelPingMs?.() ?? null },
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
catch (e) {
|
|
230
|
-
return this.json(res, 500, { error: `Could not read telemetry: ${e.message}` });
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
async communityEvents(res, url) {
|
|
234
|
-
const after = Math.max(0, Number(url.searchParams.get("after")) || 0);
|
|
235
|
-
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 60));
|
|
236
|
-
try {
|
|
237
|
-
// `started` / `completed` are counters, not log lines; the feed is the human-readable stream.
|
|
238
|
-
const rows = (await this.d.store.userEventsRecent(after, limit * 3)).filter((e) => !["started", "completed"].includes(e.kind)).slice(0, limit);
|
|
239
|
-
return this.json(res, 200, { name: this.d.communityName ?? "cliped", events: rows.map((e) => ({ id: e.id, at: e.at, kind: e.kind, username: e.username, detail: e.detail })) });
|
|
240
|
-
}
|
|
241
|
-
catch (e) {
|
|
242
|
-
return this.json(res, 500, { error: `Could not read community events: ${e.message}` });
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
async events(res, url) {
|
|
246
|
-
const after = Math.max(0, Number(url.searchParams.get("after")) || 0);
|
|
247
|
-
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));
|
|
248
|
-
const k = url.searchParams.get("kind");
|
|
249
|
-
const kind = k === "http" ? "http" : k === "all" ? undefined : "tool"; // the live tail is about tool calls; HTTP housekeeping has its own table
|
|
250
|
-
try {
|
|
251
|
-
return this.json(res, 200, { events: (await this.d.store.auditRecent(after, limit, kind)).map(rowJson) });
|
|
252
|
-
}
|
|
253
|
-
catch (e) {
|
|
254
|
-
return this.json(res, 500, { error: `Could not read events: ${e.message}` });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
/** The newest reading from each bridge process that reported recently. */
|
|
259
|
-
export function latestPerNode(samples, nowMs) {
|
|
260
|
-
const m = new Map();
|
|
261
|
-
for (const x of samples)
|
|
262
|
-
if (!m.has(x.node) || x.at > m.get(x.node).at)
|
|
263
|
-
m.set(x.node, x);
|
|
264
|
-
return [...m.values()].sort((a, b) => b.at - a.at).map((x) => ({ node: x.node, version: x.version, at: x.at, ageMs: Math.max(0, nowMs - x.at), dbPingMs: x.dbPingMs, loopLagP99Ms: x.loopLagP99Ms, rssMb: x.rssMb, heapMb: x.heapMb, uptimeS: x.uptimeS }));
|
|
265
|
-
}
|
|
266
|
-
function rowJson(r) {
|
|
267
|
-
return {
|
|
268
|
-
id: r.id,
|
|
269
|
-
at: r.at,
|
|
270
|
-
kind: r.kind,
|
|
271
|
-
name: r.name,
|
|
272
|
-
mutation: r.mutation,
|
|
273
|
-
ok: r.ok,
|
|
274
|
-
status: r.status,
|
|
275
|
-
errorClass: r.errorClass,
|
|
276
|
-
totalMs: r.totalMs,
|
|
277
|
-
upstreamMs: r.upstreamMs,
|
|
278
|
-
upstreamCalls: r.upstreamCalls,
|
|
279
|
-
scope: r.scope,
|
|
280
|
-
client: r.client,
|
|
281
|
-
instance: r.instance,
|
|
282
|
-
node: r.node,
|
|
283
|
-
};
|
|
284
|
-
}
|
package/dist/panel/server.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { ipMatcher } from "./config.js";
|
|
3
|
-
/** Client address: `hops` entries from the right of X-Forwarded-For (a client can only forge the left side). */
|
|
4
|
-
export function clientAddress(req, hops) {
|
|
5
|
-
if (hops > 0) {
|
|
6
|
-
const parts = String(req.headers["x-forwarded-for"] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
7
|
-
if (parts.length >= hops)
|
|
8
|
-
return parts[parts.length - hops];
|
|
9
|
-
}
|
|
10
|
-
return req.socket.remoteAddress ?? "unknown";
|
|
11
|
-
}
|
|
12
|
-
export function createPanelServer(o) {
|
|
13
|
-
const allowed = o.allowedIps?.length ? ipMatcher(o.allowedIps) : null;
|
|
14
|
-
return createServer(async (req, res) => {
|
|
15
|
-
try {
|
|
16
|
-
const url = new URL(req.url ?? "/", "http://panel");
|
|
17
|
-
if (url.pathname === "/healthz") {
|
|
18
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
19
|
-
return void res.end('{"ok":true}');
|
|
20
|
-
}
|
|
21
|
-
if (allowed && !allowed(clientAddress(req, o.proxyHops ?? 0))) {
|
|
22
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
23
|
-
return void res.end("Forbidden");
|
|
24
|
-
}
|
|
25
|
-
if (!(await o.routes.handle(req, res, url)) && !res.headersSent) {
|
|
26
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
27
|
-
res.end('{"error":"Not found"}');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
if (!res.headersSent) {
|
|
32
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
33
|
-
res.end('{"error":"Internal error"}');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
}
|
package/docs/panel-role.sql
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
-- READ-ONLY database role for the operator panel (the separate admin dashboard).
|
|
2
|
-
-- Run as your ADMIN role after the migrations (and again after any future migration).
|
|
3
|
-
--
|
|
4
|
-
-- 1. Create the role yourself with a strong generated password (don't commit it):
|
|
5
|
-
-- create role panel_ro login password '<paste a long random password>';
|
|
6
|
-
-- 2. Run the rest of this file.
|
|
7
|
-
-- 3. Use that role in the panel's PANEL_DATABASE_URL. The bridge never sees this role; the panel never sees the bridge's.
|
|
8
|
-
--
|
|
9
|
-
-- What this role can read: the telemetry tables (audit_events, user_events, node_samples) and three views that EXCLUDE every secret
|
|
10
|
-
-- (panel_accounts, panel_grants, panel_links). It cannot read bridge.accounts (secret hashes, real usernames), bridge.account_links
|
|
11
|
-
-- (sealed Paperclip credentials), bridge.grants, tokens, codes or pending requests, and it cannot write anything.
|
|
12
|
-
|
|
13
|
-
grant usage on schema bridge to panel_ro;
|
|
14
|
-
|
|
15
|
-
grant select on bridge.panel_accounts, bridge.panel_grants, bridge.panel_links to panel_ro;
|
|
16
|
-
grant select on bridge.audit_events, bridge.user_events, bridge.node_samples to panel_ro;
|
|
17
|
-
|
|
18
|
-
-- RLS is on for every table with no policies (deny by default); admit panel_ro to the three telemetry tables only.
|
|
19
|
-
do $$
|
|
20
|
-
declare t text;
|
|
21
|
-
begin
|
|
22
|
-
foreach t in array array['audit_events', 'user_events', 'node_samples'] loop
|
|
23
|
-
execute format('drop policy if exists panel_ro_read on bridge.%I', t);
|
|
24
|
-
execute format('create policy panel_ro_read on bridge.%I for select to panel_ro using (true)', t);
|
|
25
|
-
end loop;
|
|
26
|
-
end $$;
|