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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { tools } from "../tools.js";
|
|
2
|
+
import { buildPublicOpenApi } from "./openapi.js";
|
|
3
|
+
import { assessHealth, BRIDGE_SILENT_MS, latestPerNode } from "./health.js";
|
|
4
|
+
import { AUTH_FAILURE_REASONS, ERROR_CLASSES, PUBLIC_SCHEMA_VERSION, PUBLIC_WINDOWS, } from "./types.js";
|
|
5
|
+
const REPO_CACHE_MS = 3_600_000;
|
|
6
|
+
async function githubRepo(repo) {
|
|
7
|
+
const r = await fetch(`https://api.github.com/repos/${repo}`, { headers: { accept: "application/vnd.github+json", "user-agent": "papercliped-bridge" }, signal: AbortSignal.timeout(5000) });
|
|
8
|
+
if (!r.ok)
|
|
9
|
+
throw new Error(`github ${r.status}`);
|
|
10
|
+
const j = (await r.json());
|
|
11
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
|
|
12
|
+
return { stars: n(j.stargazers_count), forks: n(j.forks_count), openIssues: n(j.open_issues_count) };
|
|
13
|
+
}
|
|
14
|
+
const WINDOW_SPEC = {
|
|
15
|
+
"1h": { span: 3_600_000, bucket: 300_000 }, // 12 buckets of 5 minutes
|
|
16
|
+
"24h": { span: 86_400_000, bucket: 3_600_000 }, // 24 hourly buckets
|
|
17
|
+
"7d": { span: 7 * 86_400_000, bucket: 6 * 3_600_000 }, // 28 buckets of 6 hours
|
|
18
|
+
};
|
|
19
|
+
const CACHE_MS = 30_000;
|
|
20
|
+
const RATE_PER_MIN = 60;
|
|
21
|
+
const TOOL_NAMES = new Set(tools.map((t) => t.name));
|
|
22
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
23
|
+
const zero = (keys) => Object.fromEntries(keys.map((k) => [k, 0]));
|
|
24
|
+
const ENDPOINTS = [
|
|
25
|
+
{ path: "/api/public/v1/info", description: "This document: version, endpoints, limits." },
|
|
26
|
+
{ path: "/api/public/v1/openapi.json", description: "OpenAPI description of this public API." },
|
|
27
|
+
{ path: "/api/public/v1/status", description: "Is the service up: ok, degraded or down." },
|
|
28
|
+
{ path: "/api/public/v1/stats?window=1h|24h|7d", description: "Aggregate users, connections, sign-ins, requests, errors and load." },
|
|
29
|
+
{ path: "/api/public/v1/series?window=1h|24h|7d", description: "The same numbers over time, in fixed buckets." },
|
|
30
|
+
{ path: "/api/public/v1/errors?window=1h|24h|7d", description: "Error mix by class and sign-in failures by reason." },
|
|
31
|
+
{ path: "/api/public/v1/repo", description: "GitHub stars, forks and open issues of the project (cached for an hour)." },
|
|
32
|
+
{ path: "/api/public/stats", description: "Legacy: total users and live connections." },
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* GET /api/public/v1/*: system-level numbers anyone may read. Aggregate only (see types.ts), cached for 30 s, rate-limited
|
|
36
|
+
* per client address, CORS-open for GET with no credentials.
|
|
37
|
+
*/
|
|
38
|
+
export class PublicApiRoutes {
|
|
39
|
+
o;
|
|
40
|
+
now;
|
|
41
|
+
startedAt;
|
|
42
|
+
cache = new Map();
|
|
43
|
+
repoState = { at: 0, v: null, inflight: null };
|
|
44
|
+
constructor(o) {
|
|
45
|
+
this.o = o;
|
|
46
|
+
this.now = o.now ?? Date.now;
|
|
47
|
+
this.startedAt = o.startedAt ?? this.now();
|
|
48
|
+
}
|
|
49
|
+
ip(req) {
|
|
50
|
+
const hops = this.o.proxyHops ?? 0;
|
|
51
|
+
if (hops > 0) {
|
|
52
|
+
const parts = String(req.headers["x-forwarded-for"] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
53
|
+
if (parts.length >= hops)
|
|
54
|
+
return parts[parts.length - hops];
|
|
55
|
+
}
|
|
56
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
57
|
+
}
|
|
58
|
+
send(res, status, body, extra = {}) {
|
|
59
|
+
res.writeHead(status, {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Access-Control-Allow-Origin": "*",
|
|
62
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
63
|
+
"Access-Control-Max-Age": "86400",
|
|
64
|
+
"X-Content-Type-Options": "nosniff",
|
|
65
|
+
"Cache-Control": status === 200 ? `public, max-age=${CACHE_MS / 1000}` : "no-store",
|
|
66
|
+
...extra,
|
|
67
|
+
});
|
|
68
|
+
res.end(JSON.stringify(body));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
async cached(key, build) {
|
|
72
|
+
const hit = this.cache.get(key);
|
|
73
|
+
if (hit && this.now() - hit.at < CACHE_MS)
|
|
74
|
+
return hit.body;
|
|
75
|
+
const body = await build();
|
|
76
|
+
this.cache.set(key, { at: this.now(), body });
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
async handle(req, res, url) {
|
|
80
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
81
|
+
if (!path.startsWith("/api/public/v1"))
|
|
82
|
+
return false;
|
|
83
|
+
if (req.method === "OPTIONS") {
|
|
84
|
+
res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Max-Age": "86400" });
|
|
85
|
+
res.end();
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
89
|
+
return this.send(res, 405, { error: "Only GET is allowed" }, { Allow: "GET, OPTIONS" });
|
|
90
|
+
if (!(await this.o.store.hit(`pubapi:${this.ip(req)}`, RATE_PER_MIN, 60_000)))
|
|
91
|
+
return this.send(res, 429, { error: `Rate limit: ${RATE_PER_MIN} requests per minute` }, { "Retry-After": "60" });
|
|
92
|
+
const route = path.slice("/api/public/v1".length) || "/";
|
|
93
|
+
const w = url.searchParams.get("window") ?? "24h";
|
|
94
|
+
const needsWindow = route === "/stats" || route === "/series" || route === "/errors";
|
|
95
|
+
if (needsWindow && !PUBLIC_WINDOWS.includes(w))
|
|
96
|
+
return this.send(res, 400, { error: `window must be one of ${PUBLIC_WINDOWS.join(", ")}` });
|
|
97
|
+
const win = w;
|
|
98
|
+
try {
|
|
99
|
+
if (route === "/info")
|
|
100
|
+
return this.send(res, 200, this.info());
|
|
101
|
+
if (route === "/openapi.json")
|
|
102
|
+
return this.send(res, 200, buildPublicOpenApi(this.o.publicUrl ?? `${url.protocol}//${req.headers.host ?? "localhost"}`, this.o.version));
|
|
103
|
+
if (route === "/repo")
|
|
104
|
+
return this.send(res, 200, await this.repo());
|
|
105
|
+
if (route === "/status")
|
|
106
|
+
return this.send(res, 200, await this.cached("status", () => this.status()));
|
|
107
|
+
if (route === "/stats")
|
|
108
|
+
return this.send(res, 200, await this.cached(`stats:${win}`, () => this.stats(win)));
|
|
109
|
+
if (route === "/series")
|
|
110
|
+
return this.send(res, 200, await this.cached(`series:${win}`, () => this.series(win)));
|
|
111
|
+
if (route === "/errors")
|
|
112
|
+
return this.send(res, 200, await this.cached(`errors:${win}`, () => this.errors(win)));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return this.send(res, 503, { error: "Statistics are temporarily unavailable" });
|
|
116
|
+
}
|
|
117
|
+
return this.send(res, 404, { error: "Not found", endpoints: ENDPOINTS.map((e) => e.path) });
|
|
118
|
+
}
|
|
119
|
+
info() {
|
|
120
|
+
return { schemaVersion: PUBLIC_SCHEMA_VERSION, service: "papercliped", version: this.o.version, docs: this.o.docsUrl, endpoints: ENDPOINTS, windows: PUBLIC_WINDOWS, rateLimit: { requestsPerMinute: RATE_PER_MIN }, cacheSeconds: CACHE_MS / 1000 };
|
|
121
|
+
}
|
|
122
|
+
/** Stale-while-revalidate: always answers at once; refreshes from GitHub at most once an hour (and after a failure, after 10 min). */
|
|
123
|
+
async repo() {
|
|
124
|
+
const name = this.o.repo ?? "OpenSourcx/papercliped";
|
|
125
|
+
const st = this.repoState;
|
|
126
|
+
const age = this.now() - st.at;
|
|
127
|
+
const due = !st.v ? true : st.v.stars == null ? age > 600_000 : age > REPO_CACHE_MS;
|
|
128
|
+
if (due && !st.inflight) {
|
|
129
|
+
st.inflight = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const n = await (this.o.repoFetcher ?? githubRepo)(name);
|
|
132
|
+
st.v = { schemaVersion: PUBLIC_SCHEMA_VERSION, repo: name, url: `https://github.com/${name}`, ...n, fetchedAt: iso(this.now()) };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
st.v = st.v?.stars != null ? st.v : { schemaVersion: PUBLIC_SCHEMA_VERSION, repo: name, url: `https://github.com/${name}`, stars: null, forks: null, openIssues: null, fetchedAt: null };
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
st.at = this.now();
|
|
139
|
+
st.inflight = null;
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
if (!st.v)
|
|
143
|
+
await st.inflight; // the very first request waits (up to the 5 s timeout)
|
|
144
|
+
}
|
|
145
|
+
return st.v;
|
|
146
|
+
}
|
|
147
|
+
uptime = () => Math.max(0, Math.round((this.now() - this.startedAt) / 1000));
|
|
148
|
+
/** Service health over the last hour, from tool-call totals and the bridges' own health reports. */
|
|
149
|
+
async health() {
|
|
150
|
+
const to = this.now() + 1;
|
|
151
|
+
const [totals, samples] = await Promise.all([this.o.store.auditTotals("tool", to - 3_600_000, to), this.o.store.nodeSamples(to - 15 * 60_000)]);
|
|
152
|
+
const nodes = latestPerNode(samples, this.now());
|
|
153
|
+
const fresh = nodes.filter((n) => n.ageMs <= BRIDGE_SILENT_MS);
|
|
154
|
+
const h = assessHealth({
|
|
155
|
+
count: totals.count,
|
|
156
|
+
faults: totals.faults,
|
|
157
|
+
p95: totals.p95,
|
|
158
|
+
dbPingMs: nodes[0]?.dbPingMs ?? null,
|
|
159
|
+
loopLagP99Ms: Math.max(0, ...fresh.map((n) => n.loopLagP99Ms)),
|
|
160
|
+
slowMs: this.o.slowMs ?? 2000,
|
|
161
|
+
recentDbFailed: samples.slice(0, 3).length > 0 && samples.slice(0, 3).every((x) => x.dbPingMs == null),
|
|
162
|
+
bridgeSilentMs: nodes.length ? Math.min(...nodes.map((n) => n.ageMs)) : null,
|
|
163
|
+
});
|
|
164
|
+
// This process is answering, so "no bridge has reported yet" (e.g. a fresh start) is not an outage.
|
|
165
|
+
const status = h.state === "critical" ? "down" : h.state === "degraded" && !(h.reasons.length === 1 && h.reasons[0].startsWith("no bridge")) ? "degraded" : "ok";
|
|
166
|
+
const lead = fresh[0];
|
|
167
|
+
return { status, load: { eventLoopLagP99Ms: lead ? Math.round(lead.loopLagP99Ms) : null, dbPingMs: lead?.dbPingMs != null ? Math.round(lead.dbPingMs) : null, memoryMb: lead ? Math.round(lead.rssMb) : null, bridgesReporting: fresh.length } };
|
|
168
|
+
}
|
|
169
|
+
async status() {
|
|
170
|
+
return { schemaVersion: PUBLIC_SCHEMA_VERSION, status: (await this.health()).status, version: this.o.version, uptimeSeconds: this.uptime(), checkedAt: iso(this.now()) };
|
|
171
|
+
}
|
|
172
|
+
failuresByReason(failures) {
|
|
173
|
+
const out = zero(AUTH_FAILURE_REASONS);
|
|
174
|
+
for (const [key, n] of Object.entries(failures)) {
|
|
175
|
+
const reason = key.split(":")[1];
|
|
176
|
+
out[AUTH_FAILURE_REASONS.includes(reason) ? reason : "other"] += n;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
byClass(rows) {
|
|
181
|
+
const out = zero(ERROR_CLASSES);
|
|
182
|
+
for (const r of rows)
|
|
183
|
+
out[ERROR_CLASSES.includes(r.key) ? r.key : "other"] += r.count;
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
async stats(win) {
|
|
187
|
+
const { span } = WINDOW_SPEC[win];
|
|
188
|
+
const to = this.now() + 1;
|
|
189
|
+
const from = to - span;
|
|
190
|
+
const S = this.o.store;
|
|
191
|
+
const [totals, byTool, toolErrors, httpErrors, users, active, counts, live, health] = await Promise.all([
|
|
192
|
+
S.auditTotals("tool", from, to),
|
|
193
|
+
S.auditBreakdown("tool", "name", from, to, 100),
|
|
194
|
+
S.auditBreakdown("tool", "errorClass", from, to, 50),
|
|
195
|
+
S.auditBreakdown("http", "errorClass", from, to, 50),
|
|
196
|
+
S.countAccounts(),
|
|
197
|
+
S.auditActiveUsers(from, to),
|
|
198
|
+
S.userEventCounts(from, to),
|
|
199
|
+
S.liveGrantCount(),
|
|
200
|
+
this.health(),
|
|
201
|
+
]);
|
|
202
|
+
const started = counts.byKind.started ?? 0;
|
|
203
|
+
const completed = counts.byKind.completed ?? 0;
|
|
204
|
+
const failures = this.failuresByReason(counts.failures);
|
|
205
|
+
const failed = Object.values(failures).reduce((a, b) => a + b, 0);
|
|
206
|
+
const errorClasses = this.byClass([...toolErrors.filter((r) => r.key && r.key !== "null").map((r) => ({ key: r.key, count: r.errors || r.count })), ...httpErrors.filter((r) => r.key && r.key !== "null").map((r) => ({ key: r.key, count: r.errors || r.count }))]);
|
|
207
|
+
const tools = {};
|
|
208
|
+
for (const r of byTool)
|
|
209
|
+
if (TOOL_NAMES.has(r.key))
|
|
210
|
+
tools[r.key] = r.count; // only catalogue names, never other keys
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: PUBLIC_SCHEMA_VERSION,
|
|
213
|
+
window: win,
|
|
214
|
+
windowSeconds: span / 1000,
|
|
215
|
+
generatedAt: iso(this.now()),
|
|
216
|
+
service: { version: this.o.version, uptimeSeconds: this.uptime(), status: health.status },
|
|
217
|
+
users: { total: users, active, new: counts.byKind.joined ?? 0 },
|
|
218
|
+
connections: { live },
|
|
219
|
+
auth: { flowsStarted: started, flowsCompleted: completed, flowsFailed: failed, successRate: started > 0 ? Math.min(1, completed / started) : null, failuresByReason: failures },
|
|
220
|
+
requests: {
|
|
221
|
+
count: totals.count,
|
|
222
|
+
errors: totals.errors,
|
|
223
|
+
faults: totals.faults,
|
|
224
|
+
successRate: totals.count ? (totals.count - totals.errors) / totals.count : null,
|
|
225
|
+
latencyMs: { p50: Math.round(totals.p50), p95: Math.round(totals.p95), p99: Math.round(totals.p99) },
|
|
226
|
+
callsPerMinute: Math.round((totals.count / (span / 60_000)) * 100) / 100,
|
|
227
|
+
byTool: tools,
|
|
228
|
+
},
|
|
229
|
+
errors: { total: Object.values(errorClasses).reduce((a, b) => a + b, 0), byClass: errorClasses },
|
|
230
|
+
load: health.load,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async series(win) {
|
|
234
|
+
const { span, bucket } = WINDOW_SPEC[win];
|
|
235
|
+
const to = this.now() + 1;
|
|
236
|
+
const from = to - span;
|
|
237
|
+
const [calls, flows] = await Promise.all([this.o.store.auditSeries("tool", from, to, bucket), this.o.store.userEventSeries(from, to, bucket)]);
|
|
238
|
+
const start = Math.floor(from / bucket) * bucket;
|
|
239
|
+
const buckets = [];
|
|
240
|
+
for (let t = start; t < to; t += bucket) {
|
|
241
|
+
const c = calls.find((x) => x.t === t);
|
|
242
|
+
const f = flows.find((x) => x.t === t);
|
|
243
|
+
buckets.push({ t: iso(t), requests: c?.count ?? 0, errors: c?.errors ?? 0, faults: c?.faults ?? 0, p95Ms: Math.round(c?.p95 ?? 0), authCompleted: f?.completed ?? 0, authFailed: f?.failed ?? 0, newUsers: f?.joined ?? 0 });
|
|
244
|
+
}
|
|
245
|
+
return { schemaVersion: PUBLIC_SCHEMA_VERSION, window: win, bucketSeconds: bucket / 1000, generatedAt: iso(this.now()), buckets };
|
|
246
|
+
}
|
|
247
|
+
async errors(win) {
|
|
248
|
+
const s = await this.cached(`stats:${win}`, () => this.stats(win));
|
|
249
|
+
return { schemaVersion: PUBLIC_SCHEMA_VERSION, window: win, generatedAt: s.generatedAt, requests: s.requests.count, errorRate: s.requests.count ? s.requests.errors / s.requests.count : null, byClass: s.errors.byClass, authFailuresByReason: s.auth.failuresByReason };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public, aggregate-only Papercliped API (GET /api/public/v1/*). These types are the contract: consumers (the website's
|
|
3
|
+
* status page, the separate domain service and its dashboard) can import or copy them. Additive changes keep
|
|
4
|
+
* `schemaVersion`; anything breaking bumps it and gets a new path (/v2).
|
|
5
|
+
*
|
|
6
|
+
* Aggregate only, by design: no usernames, aliases, account/grant ids, Paperclip hosts, app names, IPs, tokens or free text,
|
|
7
|
+
* and no per-user or per-tenant breakdown. Categories are fixed lists, never values taken from user data.
|
|
8
|
+
*/
|
|
9
|
+
export const PUBLIC_SCHEMA_VERSION = 1;
|
|
10
|
+
export const PUBLIC_WINDOWS = ["1h", "24h", "7d"];
|
|
11
|
+
/** Why a sign-in or connect flow failed. Anything the bridge records outside this list is counted as `other`. */
|
|
12
|
+
export const AUTH_FAILURE_REASONS = ["bad_credentials", "rate_limited", "denied", "expired", "unreachable", "invalid_instance", "too_many_attempts", "other"];
|
|
13
|
+
/** Error classes of tool calls and protocol requests. Anything else is counted as `other`. */
|
|
14
|
+
export const ERROR_CLASSES = ["invalid_input", "insufficient_scope", "read_only", "upstream_4xx", "upstream_5xx", "upstream_unreachable", "rate_limited", "unauthorized", "internal", "other"];
|
package/dist/server.js
CHANGED
|
@@ -111,6 +111,10 @@ export function createHttpServer(config, http, opts = {}) {
|
|
|
111
111
|
const host = req.headers.host ?? `${http.host}:${http.port}`;
|
|
112
112
|
return json(res, 200, buildOpenApi(http.oauth?.issuer ?? http.publicUrl ?? `http://${host}`));
|
|
113
113
|
}
|
|
114
|
+
if (opts.publicApi && (await opts.publicApi.handle(req, res, url)))
|
|
115
|
+
return;
|
|
116
|
+
if (opts.web && (await opts.web.handle(req, res, url)))
|
|
117
|
+
return;
|
|
114
118
|
if (opts.site && (await opts.site.handle(req, res, url)))
|
|
115
119
|
return;
|
|
116
120
|
if (opts.manage && (await opts.manage.handle(req, res, url)))
|
package/dist/site/landing.js
CHANGED
|
@@ -57,7 +57,7 @@ export function landingBody() {
|
|
|
57
57
|
<p class="sub2">Pick what you would let Claude do, then see what happens when it tries.</p>
|
|
58
58
|
<div class="seg" role="group" aria-label="Access level">
|
|
59
59
|
<button type="button" data-l="read" aria-pressed="true">Read only</button>
|
|
60
|
-
<button type="button" data-l="control" aria-pressed="false">Full control
|
|
60
|
+
<button type="button" data-l="control" aria-pressed="false">Full control</button>
|
|
61
61
|
</div>
|
|
62
62
|
<div class="chat" id="chat" aria-live="polite"></div>
|
|
63
63
|
</section>
|
|
@@ -65,7 +65,7 @@ export function landingBody() {
|
|
|
65
65
|
<section class="band"><div class="big">Papercliped, not Paperclipped.<small>The little clip that connects your AI to the Paperclip you already run. No lock-in, and you can disconnect in one click.</small></div></section>
|
|
66
66
|
|
|
67
67
|
<section class="cards" style="margin-top:34px">
|
|
68
|
-
<div class="card"><h2>You choose the access</h2><p>Read only by default. Full control
|
|
68
|
+
<div class="card"><h2>You choose the access</h2><p>Read only by default. Full control is opt-in, every time you connect.</p></div>
|
|
69
69
|
<div class="card"><h2>Your keys stay sealed</h2><p>Your Paperclip key is stored encrypted and is never shown to the AI app.</p></div>
|
|
70
70
|
<div class="card"><h2>Optionally anonymous</h2><p>Appear as an alias like Ann02 in the operator's logs instead of your username.</p></div>
|
|
71
71
|
<div class="card"><h2>Manage it yourself</h2><p>Beta users get a page to see every connected app, change its access and disconnect it.</p></div>
|
|
@@ -136,7 +136,7 @@ const SCRIPT_TEXT = `
|
|
|
136
136
|
["me", "Pause the Web Engineer."],
|
|
137
137
|
["ai", "Done. The Web Engineer is paused. I will tell you before I do anything destructive.", "Change agents", true],
|
|
138
138
|
["me", "Terminate the Mail Officer."],
|
|
139
|
-
["ai", "
|
|
139
|
+
["ai", "Terminating can't be undone, so I will check first: do you want me to terminate the Mail Officer?", "Terminate agents", true]
|
|
140
140
|
]
|
|
141
141
|
};
|
|
142
142
|
function show(l) {
|
package/dist/site/markdown.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
// No imports, so the website (web/) can reuse this renderer as is.
|
|
2
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2
3
|
/**
|
|
3
4
|
* A deliberately small Markdown renderer for the project's own pages (docs, privacy, terms). Everything is HTML-escaped first;
|
|
4
5
|
* only a fixed set of constructs produces tags, and links are limited to http(s), mailto and same-site paths.
|
package/dist/site/site.js
CHANGED
|
@@ -7,12 +7,24 @@ import { LANDING_CSS, LANDING_SCRIPT, landingBody } from "./landing.js";
|
|
|
7
7
|
import { renderMarkdown } from "./markdown.js";
|
|
8
8
|
const NAV = [
|
|
9
9
|
{ slug: "getting-started", title: "Getting started" },
|
|
10
|
+
{ slug: "what-is-papercliped", title: "What is Papercliped?" },
|
|
10
11
|
{ slug: "signup", title: "Create your account" },
|
|
11
|
-
{ slug: "
|
|
12
|
+
{ slug: "connect-your-paperclip", title: "Set up your Paperclip" },
|
|
13
|
+
{ slug: "other-ai-apps", title: "Any AI app (MCP)" },
|
|
14
|
+
{ slug: "chatgpt", title: "ChatGPT setup" },
|
|
12
15
|
{ slug: "permissions", title: "Permissions" },
|
|
16
|
+
{ slug: "manage", title: "Manage connections" },
|
|
17
|
+
{ slug: "paperclip-plugin", title: "Paperclip plugin" },
|
|
13
18
|
{ slug: "anonymous-mode", title: "Anonymous mode" },
|
|
19
|
+
{ slug: "self-hosting", title: "Run it yourself" },
|
|
20
|
+
{ slug: "tools", title: "Tool reference" },
|
|
21
|
+
{ slug: "manage-api", title: "Manage API" },
|
|
22
|
+
{ slug: "public-api", title: "Public API and status" },
|
|
23
|
+
{ slug: "environment", title: "Environment variables" },
|
|
24
|
+
{ slug: "cli", title: "Command line" },
|
|
25
|
+
{ slug: "limits-and-errors", title: "Limits and errors" },
|
|
14
26
|
{ slug: "security", title: "Security" },
|
|
15
|
-
{ slug: "
|
|
27
|
+
{ slug: "faq", title: "FAQ" },
|
|
16
28
|
{ slug: "troubleshooting", title: "Troubleshooting" },
|
|
17
29
|
];
|
|
18
30
|
const CLIP = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`;
|
|
@@ -53,7 +65,7 @@ export class SiteRoutes {
|
|
|
53
65
|
constructor(o) {
|
|
54
66
|
this.o = o;
|
|
55
67
|
const dir = o.dir ?? new URL("../../site/", import.meta.url);
|
|
56
|
-
const fill = (md) => md.replaceAll("{{URL}}", o.url).replaceAll("{{CONTACT}}", o.contact).replaceAll("{{DATE}}", o.effective);
|
|
68
|
+
const fill = (md) => md.replaceAll("{{MCP}}", o.mcpUrl ?? o.url).replaceAll("{{URL}}", o.url).replaceAll("{{CONTACT}}", o.contact).replaceAll("{{DATE}}", o.effective);
|
|
57
69
|
const load = (rel) => renderMarkdown(fill(readFileSync(new URL(rel, dir), "utf8")));
|
|
58
70
|
this.pages.set("/privacy", { title: "Privacy", html: load("privacy.md") });
|
|
59
71
|
this.pages.set("/terms", { title: "Terms", html: load("terms.md") });
|
|
@@ -62,7 +74,7 @@ export class SiteRoutes {
|
|
|
62
74
|
}
|
|
63
75
|
layout(title, body, current, script) {
|
|
64
76
|
const nav = `<nav aria-label="Main"><a href="/docs/getting-started"${current.startsWith("/docs") ? " aria-current=page" : ""}>Docs</a><a href="/privacy"${current === "/privacy" ? " aria-current=page" : ""}>Privacy</a><a href="/terms"${current === "/terms" ? " aria-current=page" : ""}>Terms</a><a href="https://github.com/OpenSourcx/papercliped" rel="noopener noreferrer">GitHub</a></nav>`;
|
|
65
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>${esc(title)} · Papercliped</title>${FAVICON_LINK}<meta name="description" content="Connect Claude and ChatGPT to your Paperclip: control agents, sync and get reports, with permissions you choose."><style>${CSS}${script?.css ?? ""}</style></head><body><header><a class="brand" href="/">${CLIP}<span>Papercliped</span></a>${nav}</header>${body}<footer>Papercliped ${esc(VERSION)}
|
|
77
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>${esc(title)} · Papercliped</title>${FAVICON_LINK}<meta name="description" content="Connect Claude and ChatGPT to your Paperclip: control agents, sync and get reports, with permissions you choose."><style>${CSS}${script?.css ?? ""}</style></head><body><header><a class="brand" href="/">${CLIP}<span>Papercliped</span></a>${nav}</header>${body}<footer>Papercliped ${esc(VERSION)} · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a> · <a href="/docs/security">Security</a></footer>${script ? `<script nonce="${script.nonce}">${script.code}</script>` : ""}</body></html>`;
|
|
66
78
|
}
|
|
67
79
|
doc(path) {
|
|
68
80
|
const p = this.pages.get(path);
|
|
@@ -157,7 +157,7 @@ export class SystemSampler {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
|
-
* Writes this bridge process's health to the database every 30 s, so the
|
|
160
|
+
* Writes this bridge process's health to the database every 30 s, so the public status API and the operator dashboard can show it and notice when it STOPS
|
|
161
161
|
* (asleep on a free tier, crashed, or cut off from the database). Failures are swallowed: reporting must never hurt the bridge.
|
|
162
162
|
*/
|
|
163
163
|
export class NodeReporter {
|
package/dist/tools.js
CHANGED
|
@@ -389,6 +389,43 @@ export const tools = [
|
|
|
389
389
|
},
|
|
390
390
|
}),
|
|
391
391
|
// ───────────────────────────── escape hatch ─────────────────────────────
|
|
392
|
+
// ───────────────────────────── Papercliped itself ─────────────────────────────
|
|
393
|
+
tool({
|
|
394
|
+
name: "papercliped_service_status",
|
|
395
|
+
title: "Papercliped service status",
|
|
396
|
+
description: "Is the hosted Papercliped service working? Returns its status (ok, degraded, down), version and aggregate 24-hour numbers (requests, success rate, latency, sign-ins). Public data only; does not touch Paperclip.",
|
|
397
|
+
access: "read",
|
|
398
|
+
schema: z.object({ window: z.enum(["1h", "24h", "7d"]).optional().describe("Time window for the numbers (default 24h)") }),
|
|
399
|
+
run: async (_c, i) => {
|
|
400
|
+
const base = paperclipedUrl();
|
|
401
|
+
const get = async (path) => {
|
|
402
|
+
let r;
|
|
403
|
+
try {
|
|
404
|
+
r = await fetch(`${base}${path}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(8000) });
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
throw new Error(`Cannot reach Papercliped's status API at ${base}`);
|
|
408
|
+
}
|
|
409
|
+
if (!r.ok)
|
|
410
|
+
throw new Error(`Cannot reach Papercliped's status API at ${base} (HTTP ${r.status})`);
|
|
411
|
+
return (await r.json());
|
|
412
|
+
};
|
|
413
|
+
const w = i.window ?? "24h";
|
|
414
|
+
const [status, stats] = await Promise.all([get("/api/public/v1/status"), get(`/api/public/v1/stats?window=${w}`)]);
|
|
415
|
+
return {
|
|
416
|
+
service: base,
|
|
417
|
+
status: status.status,
|
|
418
|
+
version: status.version,
|
|
419
|
+
uptimeSeconds: status.uptimeSeconds,
|
|
420
|
+
window: w,
|
|
421
|
+
users: stats.users,
|
|
422
|
+
liveConnections: stats.connections?.live,
|
|
423
|
+
requests: { count: stats.requests?.count, successRate: stats.requests?.successRate, latencyMs: stats.requests?.latencyMs },
|
|
424
|
+
signIns: { started: stats.auth?.flowsStarted, completed: stats.auth?.flowsCompleted, successRate: stats.auth?.successRate },
|
|
425
|
+
statusPage: `${base}/status`,
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
392
429
|
tool({
|
|
393
430
|
name: "paperclip_api_request",
|
|
394
431
|
title: "Raw API request",
|
|
@@ -397,8 +434,8 @@ export const tools = [
|
|
|
397
434
|
schema: z.object({
|
|
398
435
|
method: z.enum(["GET", "POST", "PATCH", "PUT", "DELETE"]),
|
|
399
436
|
path: z.string().startsWith("/").describe("Path after /api, e.g. /companies"),
|
|
400
|
-
query: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
401
|
-
body: z.record(z.unknown()).optional(),
|
|
437
|
+
query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
438
|
+
body: z.record(z.string(), z.unknown()).optional(),
|
|
402
439
|
confirm: z.boolean().optional().describe("Required true for non-GET calls"),
|
|
403
440
|
}),
|
|
404
441
|
run: (c, i) => {
|
|
@@ -410,3 +447,12 @@ export const tools = [
|
|
|
410
447
|
}),
|
|
411
448
|
];
|
|
412
449
|
export const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
450
|
+
/** Where papercliped_service_status looks (PAPERCLIPED_URL; https, or http on localhost for development). */
|
|
451
|
+
function paperclipedUrl() {
|
|
452
|
+
const raw = (process.env.PAPERCLIPED_URL ?? "").trim() || "https://papercliped.co";
|
|
453
|
+
const u = new URL(raw);
|
|
454
|
+
const loop = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
|
|
455
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loop))
|
|
456
|
+
throw new ToolInputError("PAPERCLIPED_URL must be an https address");
|
|
457
|
+
return u.origin;
|
|
458
|
+
}
|
package/dist/web/csp.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Content-Security-Policy for the website (web/). The bridge sends it with every page, and `vite preview` sends the same
|
|
3
|
+
* one so the website's route check catches violations: everything from our own origin, nothing inline, no third parties.
|
|
4
|
+
*/
|
|
5
|
+
export const SITE_CSP = [
|
|
6
|
+
"default-src 'none'",
|
|
7
|
+
"script-src 'self'",
|
|
8
|
+
"style-src 'self'",
|
|
9
|
+
"font-src 'self'",
|
|
10
|
+
"img-src 'self' data:",
|
|
11
|
+
"connect-src 'self'",
|
|
12
|
+
"manifest-src 'self'",
|
|
13
|
+
"frame-ancestors 'none'",
|
|
14
|
+
"base-uri 'none'",
|
|
15
|
+
"form-action 'self'",
|
|
16
|
+
].join("; ");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const ROLES = ["marketing", "docs", "api", "forum"];
|
|
2
|
+
const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/;
|
|
3
|
+
export function parseHosts(raw) {
|
|
4
|
+
const map = { marketing: [], docs: [], api: [], forum: [] };
|
|
5
|
+
for (const part of (raw ?? "").split(";").map((s) => s.trim()).filter(Boolean)) {
|
|
6
|
+
const [role, list] = part.split("=");
|
|
7
|
+
const r = role?.trim().toLowerCase();
|
|
8
|
+
if (!ROLES.includes(r))
|
|
9
|
+
throw new Error(`PUBLIC_HOSTS: unknown role "${role}" (use ${ROLES.join(", ")})`);
|
|
10
|
+
for (const h of (list ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)) {
|
|
11
|
+
if (!HOST.test(h))
|
|
12
|
+
throw new Error(`PUBLIC_HOSTS: "${h}" is not a host name`);
|
|
13
|
+
if (ROLES.some((x) => map[x].includes(h)))
|
|
14
|
+
throw new Error(`PUBLIC_HOSTS: "${h}" is listed twice`);
|
|
15
|
+
map[r].push(h);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return map;
|
|
19
|
+
}
|
|
20
|
+
export function roleOf(map, host) {
|
|
21
|
+
const h = (host ?? "").toLowerCase();
|
|
22
|
+
return ROLES.find((r) => map[r].includes(h)) ?? null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
3
|
+
import { SITE_CSP } from "./csp.js";
|
|
4
|
+
import { roleOf } from "./hosts.js";
|
|
5
|
+
/** Paths the bridge itself serves on every host; the website never answers them. */
|
|
6
|
+
const PROTOCOL = /^\/(mcp|actions\/|openapi\.json$|register$|authorize|token$|revoke$|\.well-known\/|api\/|manage$|healthz$|readyz$)/;
|
|
7
|
+
const TYPES = {
|
|
8
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
9
|
+
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".xml": "application/xml; charset=utf-8",
|
|
10
|
+
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json",
|
|
11
|
+
};
|
|
12
|
+
const HEADERS = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "X-Frame-Options": "DENY" };
|
|
13
|
+
/**
|
|
14
|
+
* Serves the React website and does the host routing:
|
|
15
|
+
* marketing host → the site; /docs/* moves to the docs host when there is one
|
|
16
|
+
* docs host → the docs landing at / and each page at /<page>; old /docs/* and /topics/* links redirect there;
|
|
17
|
+
* other pages go to the marketing host
|
|
18
|
+
* api host → protocol endpoints only (handled elsewhere); pages redirect to the marketing host
|
|
19
|
+
* forum host → redirects to the forum
|
|
20
|
+
* Hosts not in PUBLIC_HOSTS (and a bridge with no PUBLIC_HOSTS) get the whole site, as before.
|
|
21
|
+
*/
|
|
22
|
+
export class WebAppRoutes {
|
|
23
|
+
o;
|
|
24
|
+
index;
|
|
25
|
+
routes;
|
|
26
|
+
/** Doc page slugs ("permissions", "faq"…), from the /docs/<slug> entries in routes.json. */
|
|
27
|
+
docSlugs;
|
|
28
|
+
constructor(o) {
|
|
29
|
+
this.o = o;
|
|
30
|
+
this.index = readFileSync(join(o.dir, "index.html"), "utf8");
|
|
31
|
+
this.routes = new Set(JSON.parse(readFileSync(join(o.dir, "routes.json"), "utf8")));
|
|
32
|
+
this.docSlugs = new Set([...this.routes].filter((r) => r.startsWith("/docs/")).map((r) => r.slice("/docs/".length)));
|
|
33
|
+
}
|
|
34
|
+
/** Finds web/dist next to the package, or WEB_DIST. Null when the website isn't built (then the bridge's own pages are used). */
|
|
35
|
+
static locate(env, fallback) {
|
|
36
|
+
const dir = env.WEB_DIST?.trim() || fallback;
|
|
37
|
+
return existsSync(join(dir, "index.html")) && existsSync(join(dir, "routes.json")) ? dir : null;
|
|
38
|
+
}
|
|
39
|
+
canonical(role) {
|
|
40
|
+
return this.o.hosts[role][0];
|
|
41
|
+
}
|
|
42
|
+
redirect(res, to, permanent = true) {
|
|
43
|
+
res.writeHead(permanent ? 301 : 302, { Location: to, "Cache-Control": permanent ? "public, max-age=3600" : "no-store", ...HEADERS });
|
|
44
|
+
res.end();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
/** The page shell, with the host map for the app's links (see web/src/lib/hosts.ts). */
|
|
48
|
+
shell(res, status, head) {
|
|
49
|
+
const map = JSON.stringify({ marketing: this.canonical("marketing"), docs: this.canonical("docs") }).replace(/"/g, """);
|
|
50
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache", "Content-Security-Policy": SITE_CSP, ...HEADERS });
|
|
51
|
+
res.end(head ? undefined : this.index.replace('<meta name="pcl-hosts" content="{}" />', `<meta name="pcl-hosts" content="${map}" />`));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
file(res, path, head) {
|
|
55
|
+
let rel;
|
|
56
|
+
try {
|
|
57
|
+
rel = normalize(decodeURIComponent(path)).replace(/^([/\\])+/, "");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (!rel || rel.split(/[/\\]/).includes("..") || rel === "index.html" || rel === "routes.json")
|
|
63
|
+
return false;
|
|
64
|
+
const full = join(this.o.dir, rel);
|
|
65
|
+
if (!full.startsWith(this.o.dir.endsWith(sep) ? this.o.dir : this.o.dir + sep))
|
|
66
|
+
return false;
|
|
67
|
+
let st;
|
|
68
|
+
try {
|
|
69
|
+
st = statSync(full);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!st.isFile())
|
|
75
|
+
return false;
|
|
76
|
+
const immutable = rel.startsWith("assets" + sep) || rel.startsWith("assets/") || rel.startsWith("fonts");
|
|
77
|
+
res.writeHead(200, {
|
|
78
|
+
"Content-Type": TYPES[extname(full).toLowerCase()] ?? "application/octet-stream",
|
|
79
|
+
"Content-Length": String(st.size),
|
|
80
|
+
"Cache-Control": immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
81
|
+
...(rel.endsWith(".html") ? { "Content-Security-Policy": SITE_CSP } : {}),
|
|
82
|
+
...(immutable && rel.startsWith("fonts") ? { "Access-Control-Allow-Origin": "*" } : {}),
|
|
83
|
+
...HEADERS,
|
|
84
|
+
});
|
|
85
|
+
res.end(head ? undefined : readFileSync(full));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
async handle(req, res, url) {
|
|
89
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
90
|
+
return false;
|
|
91
|
+
const head = req.method === "HEAD";
|
|
92
|
+
const raw = url.pathname;
|
|
93
|
+
const path = raw.length > 1 ? raw.replace(/\/+$/, "") : raw;
|
|
94
|
+
if (PROTOCOL.test(path))
|
|
95
|
+
return false;
|
|
96
|
+
const host = String(req.headers.host ?? "").toLowerCase();
|
|
97
|
+
const role = roleOf(this.o.hosts, host);
|
|
98
|
+
const q = url.search;
|
|
99
|
+
const mk = this.canonical("marketing");
|
|
100
|
+
const docs = this.canonical("docs");
|
|
101
|
+
const isDocs = /^\/(docs|topics)(\/|$)/.test(path);
|
|
102
|
+
const docsRest = isDocs ? path.replace(/^\/(docs|topics)\/?/, "") : "";
|
|
103
|
+
if (role === "forum")
|
|
104
|
+
return this.redirect(res, this.o.forumUrl || (mk ? `https://${mk}/community` : "/"), false);
|
|
105
|
+
if (role === "api")
|
|
106
|
+
return mk ? this.redirect(res, `https://${mk}${path === "/" ? "/" : path}${q}`, false) : false;
|
|
107
|
+
if (role === "marketing" && mk && host !== mk)
|
|
108
|
+
return this.redirect(res, `https://${mk}${raw}${q}`); // www → apex
|
|
109
|
+
if (role === "docs") {
|
|
110
|
+
if (isDocs)
|
|
111
|
+
return this.redirect(res, `/${docsRest}${q}`); // old /docs/x and /topics/x links
|
|
112
|
+
if (path === "/" || this.docSlugs.has(path.slice(1)))
|
|
113
|
+
return this.shell(res, 200, head);
|
|
114
|
+
if (this.routes.has(path) && mk)
|
|
115
|
+
return this.redirect(res, `https://${mk}${path}${q}`);
|
|
116
|
+
}
|
|
117
|
+
else if (isDocs) {
|
|
118
|
+
// Marketing (or unlisted) host: docs move to the docs host when there is one. /topics is an old spelling.
|
|
119
|
+
if (docs)
|
|
120
|
+
return this.redirect(res, `https://${docs}/${docsRest}${q}`);
|
|
121
|
+
if (/^\/topics(\/|$)/.test(path))
|
|
122
|
+
return this.redirect(res, `/docs${docsRest ? `/${docsRest}` : ""}${q}`);
|
|
123
|
+
}
|
|
124
|
+
if (path !== "/" && this.file(res, raw, head))
|
|
125
|
+
return true;
|
|
126
|
+
if (this.routes.has(path))
|
|
127
|
+
return this.shell(res, 200, head);
|
|
128
|
+
if (extname(path))
|
|
129
|
+
return false; // a missing file: let the bridge answer 404
|
|
130
|
+
return this.shell(res, 404, head); // the app shows its not-found page
|
|
131
|
+
}
|
|
132
|
+
}
|