papercliped 1.1.0-beta.2 → 2.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.
Files changed (83) hide show
  1. package/.claude-plugin/marketplace.json +14 -11
  2. package/.claude-plugin/plugin.json +17 -8
  3. package/.mcp.json +8 -14
  4. package/CHANGELOG.md +64 -1
  5. package/LICENSE +21 -21
  6. package/README.md +80 -80
  7. package/commands/paperclip-report.md +5 -5
  8. package/commands/paperclip-status.md +4 -4
  9. package/commands/paperclip-sync.md +5 -5
  10. package/dist/accounts/alias.js +1 -1
  11. package/dist/accounts/username.js +1 -1
  12. package/dist/cli.js +11 -11
  13. package/dist/http.js +17 -3
  14. package/dist/manage/routes.js +60 -82
  15. package/dist/oauth/pages.js +28 -18
  16. package/dist/oauth/pg-store.js +0 -13
  17. package/dist/oauth/provider.js +27 -63
  18. package/dist/oauth/scopes.js +19 -16
  19. package/dist/openapi.js +19 -2
  20. package/dist/public-api/health.js +46 -0
  21. package/dist/public-api/openapi.js +51 -0
  22. package/dist/public-api/routes.js +251 -0
  23. package/dist/public-api/types.js +14 -0
  24. package/dist/server.js +4 -0
  25. package/dist/site/landing.js +3 -3
  26. package/dist/site/markdown.js +2 -1
  27. package/dist/site/site.js +16 -4
  28. package/dist/telemetry/recorder.js +1 -1
  29. package/dist/tools.js +48 -2
  30. package/dist/web/csp.js +16 -0
  31. package/dist/web/hosts.js +23 -0
  32. package/dist/web/routes.js +132 -0
  33. package/docs/ARCHITECTURE.md +74 -73
  34. package/docs/COMMUNITY.md +86 -0
  35. package/docs/DEPLOY-RENDER.md +3 -3
  36. package/docs/DOMAINS.md +74 -0
  37. package/docs/LAUNCH.md +50 -34
  38. package/docs/RELEASING.md +37 -0
  39. package/docs/SECURITY.md +82 -65
  40. package/docs/SITEMAP.md +92 -0
  41. package/docs/V3-SUBDOMAINS.md +106 -0
  42. package/docs/chatgpt.md +20 -20
  43. package/docs/least-privilege.sql +23 -23
  44. package/docs/legal/PRIVACY.md +37 -37
  45. package/docs/legal/TERMS.md +11 -11
  46. package/docs/oauth.md +61 -62
  47. package/docs/supabase-schema.sql +8 -1
  48. package/migrations/001_init.sql +84 -84
  49. package/migrations/002_audit.sql +41 -41
  50. package/migrations/003_accounts.sql +66 -66
  51. package/migrations/004_privacy_panel.sql +57 -57
  52. package/migrations/006_two_levels.sql +5 -0
  53. package/package.json +10 -14
  54. package/site/docs/chatgpt.md +1 -1
  55. package/site/docs/cli.md +27 -0
  56. package/site/docs/connect-your-paperclip.md +120 -0
  57. package/site/docs/environment.md +64 -0
  58. package/site/docs/faq.md +31 -0
  59. package/site/docs/getting-started.md +11 -5
  60. package/site/docs/limits-and-errors.md +32 -0
  61. package/site/docs/manage-api.md +33 -0
  62. package/site/docs/manage.md +12 -21
  63. package/site/docs/other-ai-apps.md +28 -0
  64. package/site/docs/paperclip-plugin.md +32 -0
  65. package/site/docs/permissions.md +5 -5
  66. package/site/docs/public-api.md +62 -0
  67. package/site/docs/security.md +2 -2
  68. package/site/docs/self-hosting.md +43 -0
  69. package/site/docs/signup.md +3 -3
  70. package/site/docs/tools.md +47 -0
  71. package/site/docs/troubleshooting.md +2 -2
  72. package/site/docs/what-is-papercliped.md +37 -0
  73. package/site/privacy.md +5 -3
  74. package/site/terms.md +1 -1
  75. package/skills/paperclip/SKILL.md +23 -23
  76. package/dist/demo.js +0 -144
  77. package/dist/manage/page.js +0 -170
  78. package/dist/panel/config.js +0 -61
  79. package/dist/panel/main.js +0 -90
  80. package/dist/panel/page.js +0 -553
  81. package/dist/panel/routes.js +0 -284
  82. package/dist/panel/server.js +0 -37
  83. package/docs/panel-role.sql +0 -26
package/dist/openapi.js CHANGED
@@ -1,4 +1,4 @@
1
- import { zodToJsonSchema } from "zod-to-json-schema";
1
+ import { z } from "zod";
2
2
  import { VERSION as SERVER_VERSION } from "./version.js";
3
3
  import { tools } from "./tools.js";
4
4
  /**
@@ -6,10 +6,27 @@ import { tools } from "./tools.js";
6
6
  * catalogue the MCP server uses, so the two surfaces cannot drift.
7
7
  * Each tool is `POST /actions/{name}` with the tool input as the JSON body.
8
8
  */
9
+ /**
10
+ * Zod's JSON Schema for a tool input, shaped like the hand-checked contract ChatGPT Actions have always seen: objects with
11
+ * fixed properties reject unknown keys, and Zod-only string formats (e.g. "starts_with") are left out.
12
+ */
13
+ function tidy(node) {
14
+ if (Array.isArray(node))
15
+ return node.map(tidy);
16
+ if (!node || typeof node !== "object")
17
+ return node;
18
+ const out = {};
19
+ for (const [k, v] of Object.entries(node))
20
+ if (!(k === "format" && v === "starts_with"))
21
+ out[k] = tidy(v);
22
+ if (out.type === "object" && out.properties && !("additionalProperties" in out))
23
+ out.additionalProperties = false;
24
+ return out;
25
+ }
9
26
  export function buildOpenApi(serverUrl) {
10
27
  const paths = {};
11
28
  for (const t of tools) {
12
- const { $schema, ...schema } = zodToJsonSchema(t.schema, { target: "openApi3" });
29
+ const { $schema, ...schema } = tidy(z.toJSONSchema(t.schema, { target: "openapi-3.0", io: "input" }));
13
30
  paths[`/actions/${t.name}`] = {
14
31
  post: {
15
32
  operationId: t.name,
@@ -0,0 +1,46 @@
1
+ /** Bridges report every 30 s. Silence for this long means it is down, asleep (free tier), or cannot reach the database. */
2
+ export const BRIDGE_SILENT_MS = 120_000;
3
+ export function assessHealth(i) {
4
+ const reasons = [];
5
+ let level = 0;
6
+ const bump = (l, why) => {
7
+ level = Math.max(level, l);
8
+ reasons.push(why);
9
+ };
10
+ const faultRate = i.count ? i.faults / i.count : 0;
11
+ if (i.count >= 5) {
12
+ if (faultRate >= 0.1)
13
+ bump(2, `${(faultRate * 100).toFixed(1)}% of calls hit a system fault`);
14
+ else if (faultRate >= 0.02)
15
+ bump(1, `${(faultRate * 100).toFixed(1)}% of calls hit a system fault`);
16
+ if (i.p95 >= i.slowMs * 3)
17
+ bump(2, `p95 latency ${Math.round(i.p95)} ms`);
18
+ else if (i.p95 >= i.slowMs)
19
+ bump(1, `p95 latency ${Math.round(i.p95)} ms`);
20
+ }
21
+ if (i.bridgeSilentMs === null)
22
+ bump(1, "no bridge has reported yet");
23
+ else if (i.bridgeSilentMs > BRIDGE_SILENT_MS)
24
+ bump(2, `the bridge has not reported for ${Math.round(i.bridgeSilentMs / 1000)} s (asleep, down, or cannot reach the database)`);
25
+ if (i.recentDbFailed)
26
+ bump(2, "database ping is failing");
27
+ else if (i.dbPingMs != null && i.dbPingMs >= 1000)
28
+ bump(2, `database ping ${Math.round(i.dbPingMs)} ms`);
29
+ else if (i.dbPingMs != null && i.dbPingMs >= 250)
30
+ bump(1, `database ping ${Math.round(i.dbPingMs)} ms`);
31
+ if (i.loopLagP99Ms >= 500)
32
+ bump(2, `event-loop lag p99 ${Math.round(i.loopLagP99Ms)} ms`);
33
+ else if (i.loopLagP99Ms >= 100)
34
+ bump(1, `event-loop lag p99 ${Math.round(i.loopLagP99Ms)} ms`);
35
+ if (level === 0)
36
+ return { state: i.count === 0 ? "idle" : "healthy", reasons: i.count === 0 ? ["no calls in this window"] : [] };
37
+ return { state: level === 2 ? "critical" : "degraded", reasons };
38
+ }
39
+ /** The newest reading from each bridge process that reported recently. */
40
+ export function latestPerNode(samples, nowMs) {
41
+ const m = new Map();
42
+ for (const x of samples)
43
+ if (!m.has(x.node) || x.at > m.get(x.node).at)
44
+ m.set(x.node, x);
45
+ 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 }));
46
+ }
@@ -0,0 +1,51 @@
1
+ import { AUTH_FAILURE_REASONS, ERROR_CLASSES, PUBLIC_SCHEMA_VERSION, PUBLIC_WINDOWS } from "./types.js";
2
+ /**
3
+ * OpenAPI for the public, aggregate-only API. Kept separate from /openapi.json on purpose: that document is imported as
4
+ * ChatGPT Actions, and these system statistics are not tools the model should call.
5
+ */
6
+ export function buildPublicOpenApi(serverUrl, version) {
7
+ const win = { name: "window", in: "query", required: false, schema: { type: "string", enum: [...PUBLIC_WINDOWS], default: "24h" } };
8
+ const counts = (keys) => ({ type: "object", properties: Object.fromEntries(keys.map((k) => [k, { type: "integer" }])), required: [...keys] });
9
+ const ok = (ref) => ({
10
+ "200": { description: "OK (cached up to 30 s)", content: { "application/json": { schema: { $ref: `#/components/schemas/${ref}` } } } },
11
+ "400": { description: "Invalid window" },
12
+ "429": { description: "Rate limited (60 requests per minute per address)" },
13
+ "503": { description: "Statistics temporarily unavailable" },
14
+ });
15
+ const latency = { type: "object", properties: { p50: { type: "number" }, p95: { type: "number" }, p99: { type: "number" } } };
16
+ return {
17
+ openapi: "3.1.0",
18
+ info: { title: "Papercliped public API", version, description: `Aggregate-only system statistics (schemaVersion ${PUBLIC_SCHEMA_VERSION}). No identities, hosts or per-user data. CORS-open for GET, no credentials.` },
19
+ servers: [{ url: serverUrl }],
20
+ tags: [{ name: "public", description: "Public, read-only, aggregate statistics" }],
21
+ paths: {
22
+ "/api/public/v1/info": { get: { tags: ["public"], operationId: "publicInfo", summary: "API info", responses: ok("PublicInfo") } },
23
+ "/api/public/v1/status": { get: { tags: ["public"], operationId: "publicStatus", summary: "Service status", responses: ok("PublicStatus") } },
24
+ "/api/public/v1/stats": { get: { tags: ["public"], operationId: "publicStats", summary: "Aggregate statistics", parameters: [win], responses: ok("PublicStats") } },
25
+ "/api/public/v1/series": { get: { tags: ["public"], operationId: "publicSeries", summary: "Statistics over time", parameters: [win], responses: ok("PublicSeries") } },
26
+ "/api/public/v1/repo": { get: { tags: ["public"], operationId: "publicRepo", summary: "GitHub stars, forks and open issues (cached for an hour)", responses: { "200": { description: "OK", content: { "application/json": { schema: { type: "object", properties: { repo: { type: "string" }, url: { type: "string" }, stars: { type: ["integer", "null"] }, forks: { type: ["integer", "null"] }, openIssues: { type: ["integer", "null"] }, fetchedAt: { type: ["string", "null"] } } } } } } } } },
27
+ "/api/public/v1/errors": { get: { tags: ["public"], operationId: "publicErrors", summary: "Error mix", parameters: [win], responses: ok("PublicErrors") } },
28
+ },
29
+ components: {
30
+ schemas: {
31
+ PublicInfo: { type: "object", properties: { schemaVersion: { type: "integer" }, service: { type: "string" }, version: { type: "string" }, docs: { type: "string" }, endpoints: { type: "array", items: { type: "object", properties: { path: { type: "string" }, description: { type: "string" } } } } } },
32
+ PublicStatus: { type: "object", properties: { schemaVersion: { type: "integer" }, status: { type: "string", enum: ["ok", "degraded", "down"] }, version: { type: "string" }, uptimeSeconds: { type: "integer" }, checkedAt: { type: "string", format: "date-time" } } },
33
+ PublicStats: {
34
+ type: "object",
35
+ properties: {
36
+ schemaVersion: { type: "integer" }, window: { type: "string" }, windowSeconds: { type: "integer" }, generatedAt: { type: "string", format: "date-time" },
37
+ service: { type: "object", properties: { version: { type: "string" }, uptimeSeconds: { type: "integer" }, status: { type: "string" } } },
38
+ users: { type: "object", properties: { total: { type: "integer" }, active: { type: "integer" }, new: { type: "integer" } } },
39
+ connections: { type: "object", properties: { live: { type: "integer" } } },
40
+ auth: { type: "object", properties: { flowsStarted: { type: "integer" }, flowsCompleted: { type: "integer" }, flowsFailed: { type: "integer" }, successRate: { type: ["number", "null"] }, failuresByReason: counts(AUTH_FAILURE_REASONS) } },
41
+ requests: { type: "object", properties: { count: { type: "integer" }, errors: { type: "integer" }, faults: { type: "integer" }, successRate: { type: ["number", "null"] }, latencyMs: latency, callsPerMinute: { type: "number" }, byTool: { type: "object", additionalProperties: { type: "integer" }, description: "Calls per tool name from the public tool catalogue" } } },
42
+ errors: { type: "object", properties: { total: { type: "integer" }, byClass: counts(ERROR_CLASSES) } },
43
+ load: { type: "object", properties: { eventLoopLagP99Ms: { type: ["number", "null"] }, dbPingMs: { type: ["number", "null"] }, memoryMb: { type: ["number", "null"] }, bridgesReporting: { type: "integer" } } },
44
+ },
45
+ },
46
+ PublicSeries: { type: "object", properties: { schemaVersion: { type: "integer" }, window: { type: "string" }, bucketSeconds: { type: "integer" }, buckets: { type: "array", items: { type: "object", properties: { t: { type: "string", format: "date-time" }, requests: { type: "integer" }, errors: { type: "integer" }, faults: { type: "integer" }, p95Ms: { type: "number" }, authCompleted: { type: "integer" }, authFailed: { type: "integer" }, newUsers: { type: "integer" } } } } } },
47
+ PublicErrors: { type: "object", properties: { schemaVersion: { type: "integer" }, window: { type: "string" }, requests: { type: "integer" }, errorRate: { type: ["number", "null"] }, byClass: counts(ERROR_CLASSES), authFailuresByReason: counts(AUTH_FAILURE_REASONS) } },
48
+ },
49
+ },
50
+ };
51
+ }
@@ -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)))
@@ -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 (beta)</button>
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 (beta) is opt-in, every time you connect.</p></div>
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", "That needs Admin access, which you did not give me. I can pause it instead.", "Terminate agents", false]
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) {
@@ -1,4 +1,5 @@
1
- import { esc } from "../oauth/pages.js";
1
+ // No imports, so the website (web/) can reuse this renderer as is.
2
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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: "manage", title: "Manage connections (beta)" },
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: "chatgpt", title: "ChatGPT setup" },
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)} (beta) · <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>`;
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 separate panel can show it and notice when it STOPS
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
+ }
@@ -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
+ }