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/oauth/provider.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { hkdfSync } from "node:crypto";
|
|
2
1
|
import { generateSecretKey, burnVerify, hashSecretKey, normalizeSecretKey, verifySecretKey } from "../accounts/secret.js";
|
|
3
2
|
import { anonInstanceLabel, displayName, generateAlias } from "../accounts/alias.js";
|
|
4
3
|
import { AliasTakenError } from "../accounts/types.js";
|
|
@@ -9,13 +8,20 @@ import { UnsafeUrlError, createSafeFetch, parseInstanceUrl } from "../net/safe-f
|
|
|
9
8
|
import { Keyring, isValidCodeChallenge, randomToken, safeEqual, sha256Hex, verifyPkce } from "./crypto.js";
|
|
10
9
|
import { approvePage, choosePage, consentPage, errorPage, instancePage, scopePage, secretPage, usernamePage, welcomePage } from "./pages.js";
|
|
11
10
|
import { LoginError, PaperclipLogin } from "./paperclip-login.js";
|
|
11
|
+
import { VERSION } from "../version.js";
|
|
12
12
|
import { MemoryStore } from "./store.js";
|
|
13
|
-
import { SCOPES, grantableScopes, isScope, maxRank, scopeAllows, scopesUpTo } from "./scopes.js";
|
|
13
|
+
import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
|
|
14
14
|
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
15
15
|
const PENDING_TTL_MS = 10 * 60_000;
|
|
16
16
|
const CODE_TTL_MS = 60_000;
|
|
17
17
|
const MAX_PENDING = 5000;
|
|
18
18
|
const MAX_INSTANCE_ATTEMPTS = 5;
|
|
19
|
+
/** The plugin offered on the instance step: one fixed package, pinned to this bridge's own version (both are released together). */
|
|
20
|
+
const PLUGIN_PACKAGE = "papercliped-paperclip-plugin";
|
|
21
|
+
/** Its id inside Paperclip (the manifest id), used to see whether it is already installed. */
|
|
22
|
+
const PLUGIN_KEY = "papercliped.remote-control";
|
|
23
|
+
/** Paperclip runs npm on its side for an install; allow it more time than ordinary calls. */
|
|
24
|
+
const PLUGIN_INSTALL_MS = 120_000;
|
|
19
25
|
const MAX_FORM_BYTES = 16 * 1024;
|
|
20
26
|
const DAY_MS = 24 * 3600 * 1000;
|
|
21
27
|
class OAuthError extends Error {
|
|
@@ -59,6 +65,10 @@ export class OAuthProvider {
|
|
|
59
65
|
keyring;
|
|
60
66
|
now;
|
|
61
67
|
safeFetch;
|
|
68
|
+
/** Longer-lived egress-guarded fetch for the plugin install, which makes Paperclip run npm. */
|
|
69
|
+
installFetch;
|
|
70
|
+
/** Plugin installs running after a sign-in finished, so tests (and shutdown) can wait for them. */
|
|
71
|
+
background = new Set();
|
|
62
72
|
multi;
|
|
63
73
|
accounts;
|
|
64
74
|
constructor(deps) {
|
|
@@ -69,6 +79,7 @@ export class OAuthProvider {
|
|
|
69
79
|
this.multi = deps.oauth.mode === "multi";
|
|
70
80
|
this.accounts = deps.oauth.accounts && this.multi;
|
|
71
81
|
this.safeFetch = deps.safeFetch ?? createSafeFetch({ timeoutMs: deps.config.timeoutMs, maxBytes: 2_000_000 });
|
|
82
|
+
this.installFetch = deps.safeFetch ?? createSafeFetch({ timeoutMs: PLUGIN_INSTALL_MS, maxBytes: 200_000 });
|
|
72
83
|
this.resource = `${deps.oauth.issuer}/mcp`;
|
|
73
84
|
this.metadataUrl = `${deps.oauth.issuer}/.well-known/oauth-protected-resource`;
|
|
74
85
|
}
|
|
@@ -83,7 +94,7 @@ export class OAuthProvider {
|
|
|
83
94
|
/* telemetry only */
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
|
-
/** The name logs and the
|
|
97
|
+
/** The name logs and the operator dashboard show for an account: its alias if it opted into anonymity, else its username. */
|
|
87
98
|
async who(accountId) {
|
|
88
99
|
if (!accountId)
|
|
89
100
|
return null;
|
|
@@ -222,8 +233,8 @@ export class OAuthProvider {
|
|
|
222
233
|
/* best effort */
|
|
223
234
|
}
|
|
224
235
|
}
|
|
225
|
-
// ───────────── account management (the
|
|
226
|
-
// Everything here acts on ONE account and is reached only through ManageRoutes
|
|
236
|
+
// ───────────── account management (the Paperclip plugin's connection manager) ─────────────
|
|
237
|
+
// Everything here acts on ONE account and is reached only through ManageRoutes, with a plugin token or the secret key.
|
|
227
238
|
/** Username + secret key, throttled exactly like the sign-in page (shared counters, so there is no cheaper way to guess). */
|
|
228
239
|
async manageLogin(req, username, secret) {
|
|
229
240
|
const typed = username.trim().slice(0, 64);
|
|
@@ -249,45 +260,21 @@ export class OAuthProvider {
|
|
|
249
260
|
return false;
|
|
250
261
|
return verifySecretKey(secret, a.secretHash);
|
|
251
262
|
}
|
|
252
|
-
|
|
253
|
-
manageSigningKey() {
|
|
254
|
-
return Buffer.from(hkdfSync("sha256", this.deps.oauth.secret, "papercliped", "manage-session-v1", 32));
|
|
255
|
-
}
|
|
256
|
-
// ── the Paperclip plugin link: a one-time code (typed into the plugin) is exchanged for a long-lived plugin token ──
|
|
263
|
+
// ── the Paperclip plugin link: the plugin signs in once with username + secret key and gets a long-lived plugin token ──
|
|
257
264
|
// The token is stored as a hash on a synthetic grant (client "paperclip-plugin", NO scopes), so it appears in no tool list,
|
|
258
265
|
// cannot call any Paperclip tool, and is revoked like any grant. It only opens the manage API for its own account.
|
|
259
|
-
/** A
|
|
260
|
-
async
|
|
261
|
-
const raw = randomToken(9).replace(/[^A-Za-z0-9]/g, "").toUpperCase().padEnd(10, "7").slice(0, 10);
|
|
262
|
-
const code = `pcl_${raw.slice(0, 5)}-${raw.slice(5)}`;
|
|
263
|
-
const ttl = 10 * 60_000;
|
|
264
|
-
await this.store.putCode(sha256Hex(code), { clientId: "plugin-link", redirectUri: "", codeChallenge: "", scopes: [], clientName: "Paperclip plugin", userId: null, instanceUrl: null, sealedCredential: null, accountId: a.id, username: displayName(a), path: "plugin", expiresAt: this.now() + ttl });
|
|
265
|
-
await this.event("updated", { accountId: a.id, username: displayName(a) }, "plugin_code");
|
|
266
|
-
return { code, expiresInSec: ttl / 1000 };
|
|
267
|
-
}
|
|
268
|
-
/** Trade a link code for a plugin token. Wrong, used and expired codes all look the same. */
|
|
269
|
-
async manageExchangeLinkCode(req, code, meta) {
|
|
270
|
-
if (!(await this.limit(`plink:ip:${this.ip(req)}`, 20)))
|
|
271
|
-
return null;
|
|
272
|
-
const norm = code.trim().toUpperCase();
|
|
273
|
-
if (!/^PCL_[A-Z0-9]{5}-[A-Z0-9]{5}$/.test(norm))
|
|
274
|
-
return null;
|
|
275
|
-
const taken = await this.store.takeCode(sha256Hex(`pcl_${norm.slice(4)}`));
|
|
276
|
-
if (taken.status !== "ok" || taken.record.clientId !== "plugin-link" || taken.record.expiresAt < this.now() || !taken.record.accountId)
|
|
277
|
-
return null;
|
|
278
|
-
const a = await this.store.getAccount(taken.record.accountId);
|
|
279
|
-
if (!a || a.disabled)
|
|
280
|
-
return null;
|
|
266
|
+
/** A plugin token for an account whose owner just proved the secret key. */
|
|
267
|
+
async manageLinkPlugin(a, meta) {
|
|
281
268
|
const id = `pcb_pg_${randomToken(10)}`;
|
|
282
269
|
const token = `pcb_pl_${randomToken(32)}`;
|
|
283
270
|
await this.store.putGrant({ id, clientId: "paperclip-plugin", clientName: "Paperclip plugin", userId: meta.paperclipUserId, scopes: [], resource: "manage", instanceUrl: meta.instanceHost ? `https://${meta.instanceHost}` : null, sealedCredential: null, accountId: a.id, username: displayName(a), createdAt: this.now(), lastUsedAt: this.now(), revoked: false });
|
|
284
271
|
await this.store.putAccess(sha256Hex(token), { grantId: id, expiresAt: this.now() + 180 * DAY_MS });
|
|
285
272
|
await this.event("updated", { accountId: a.id, username: displayName(a) }, "plugin_linked");
|
|
286
|
-
return { token };
|
|
273
|
+
return { token, linkId: id };
|
|
287
274
|
}
|
|
288
|
-
/** The account a plugin token belongs to (null if unknown, expired, revoked, or not a plugin token). */
|
|
289
|
-
async
|
|
290
|
-
if (!token.startsWith("pcb_pl_"))
|
|
275
|
+
/** The account (and link id) a plugin token belongs to (null if unknown, expired, revoked, or not a plugin token). */
|
|
276
|
+
async managePluginPrincipal(token) {
|
|
277
|
+
if (!token.startsWith("pcb_pl_") || token.length > 200)
|
|
291
278
|
return null;
|
|
292
279
|
const rec = await this.store.getAccess(sha256Hex(token));
|
|
293
280
|
if (!rec || rec.expiresAt < this.now())
|
|
@@ -299,10 +286,10 @@ export class OAuthProvider {
|
|
|
299
286
|
if (!a || a.disabled)
|
|
300
287
|
return null;
|
|
301
288
|
await this.store.touchGrant(g.id);
|
|
302
|
-
return a;
|
|
289
|
+
return { account: a, linkId: g.id };
|
|
303
290
|
}
|
|
304
291
|
async manageListPluginLinks(accountId) {
|
|
305
|
-
return (await this.store.listAccountGrants(accountId)).filter((g) => g.clientId === "paperclip-plugin" && !g.revoked).map((g) => ({ id: g.id, host: g.instanceUrl ? new URL(g.instanceUrl).host : null, createdAt: g.createdAt, lastUsedAt: g.lastUsedAt }));
|
|
292
|
+
return (await this.store.listAccountGrants(accountId)).filter((g) => g.clientId === "paperclip-plugin" && !g.revoked).map((g) => ({ id: g.id, host: g.instanceUrl ? new URL(g.instanceUrl).host : null, paperclipUserId: g.userId, createdAt: g.createdAt, lastUsedAt: g.lastUsedAt }));
|
|
306
293
|
}
|
|
307
294
|
async manageRevokePluginLink(a, id) {
|
|
308
295
|
const g = await this.store.getGrant(id);
|
|
@@ -315,24 +302,16 @@ export class OAuthProvider {
|
|
|
315
302
|
manageAccount(accountId) {
|
|
316
303
|
return this.store.getAccount(accountId);
|
|
317
304
|
}
|
|
318
|
-
async manageSetBeta(a, beta) {
|
|
319
|
-
if (!!a.beta === beta)
|
|
320
|
-
return;
|
|
321
|
-
await this.store.setAccountBeta(a.id, beta);
|
|
322
|
-
await this.event("updated", { accountId: a.id, username: displayName(a) }, beta ? "beta_on" : "beta_off");
|
|
323
|
-
}
|
|
324
305
|
async manageConnections(accountId) {
|
|
325
306
|
return (await this.store.listAccountGrants(accountId))
|
|
326
307
|
.filter((g) => !g.revoked && g.clientId !== "paperclip-plugin")
|
|
327
308
|
.map((g) => ({ id: g.id, app: g.clientName, level: levelOf(g.scopes), createdAt: g.createdAt, lastUsedAt: g.lastUsedAt }));
|
|
328
309
|
}
|
|
329
|
-
/** Change what a connected app may do
|
|
310
|
+
/** Change what a connected app may do: Read only or Full control. */
|
|
330
311
|
async manageSetLevel(a, grantId, level) {
|
|
331
312
|
const g = await this.store.getGrant(grantId);
|
|
332
313
|
if (!g || g.revoked || g.accountId !== a.id || g.clientId === "paperclip-plugin")
|
|
333
314
|
return false;
|
|
334
|
-
if (level === "paperclip:admin" && levelOf(g.scopes) !== "paperclip:admin")
|
|
335
|
-
return false;
|
|
336
315
|
if (!(await this.store.setGrantScopes(g.id, scopesUpTo(level))))
|
|
337
316
|
return false;
|
|
338
317
|
await this.event("updated", { accountId: a.id, username: displayName(a) }, `level_${level.split(":")[1]}`);
|
|
@@ -352,6 +331,10 @@ export class OAuthProvider {
|
|
|
352
331
|
async manageRotateSecret(a) {
|
|
353
332
|
const secret = generateSecretKey();
|
|
354
333
|
await this.store.setAccountSecret(a.id, await hashSecretKey(normalizeSecretKey(secret)));
|
|
334
|
+
// A new secret key means "I may be compromised": every plugin link goes too (the caller relinks the plugin it came from).
|
|
335
|
+
for (const g of await this.store.listAccountGrants(a.id))
|
|
336
|
+
if (g.clientId === "paperclip-plugin" && !g.revoked)
|
|
337
|
+
await this.store.revokeGrant(g.id);
|
|
355
338
|
await this.event("updated", { accountId: a.id, username: displayName(a) }, "secret_rotated");
|
|
356
339
|
return secret;
|
|
357
340
|
}
|
|
@@ -418,6 +401,46 @@ export class OAuthProvider {
|
|
|
418
401
|
throw new UnsafeUrlError("This service is not open to that host yet.");
|
|
419
402
|
return u;
|
|
420
403
|
}
|
|
404
|
+
/** Wait for plugin installs that are still running (tests and graceful shutdown). */
|
|
405
|
+
async drainBackground() {
|
|
406
|
+
await Promise.allSettled([...this.background]);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Install the Papercliped plugin into the user's Paperclip after they approved the sign-in and ticked the box.
|
|
410
|
+
* Runs after the page has been answered: npm can take a while on their side, and the connection must not wait for it
|
|
411
|
+
* or fail because of it. The outcome is recorded as a "plugin" event and never carries anything but a short word.
|
|
412
|
+
*/
|
|
413
|
+
installPluginLater(p, token) {
|
|
414
|
+
const instanceUrl = p.instanceUrl;
|
|
415
|
+
if (!instanceUrl)
|
|
416
|
+
return;
|
|
417
|
+
const accountId = p.accountId ?? null;
|
|
418
|
+
const run = (async () => {
|
|
419
|
+
let outcome = "failed";
|
|
420
|
+
try {
|
|
421
|
+
const login = this.pluginLoginFor(instanceUrl);
|
|
422
|
+
const now = await login.pluginState(token, PLUGIN_KEY);
|
|
423
|
+
if (now.state === "unsupported")
|
|
424
|
+
outcome = "unsupported";
|
|
425
|
+
else if (now.state === "installed")
|
|
426
|
+
outcome = "already";
|
|
427
|
+
else
|
|
428
|
+
outcome = await login.installPlugin(token, PLUGIN_PACKAGE, VERSION);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
outcome = "failed";
|
|
432
|
+
}
|
|
433
|
+
await this.event("plugin", await this.who(accountId), outcome);
|
|
434
|
+
})();
|
|
435
|
+
const tracked = run.finally(() => this.background.delete(tracked));
|
|
436
|
+
this.background.add(tracked);
|
|
437
|
+
}
|
|
438
|
+
pluginLoginFor(instanceUrl) {
|
|
439
|
+
if (this.deps.loginFor)
|
|
440
|
+
return this.deps.loginFor(instanceUrl);
|
|
441
|
+
const origin = this.checkedInstance(instanceUrl).origin; // re-validated: policy can tighten between steps
|
|
442
|
+
return new PaperclipLogin({ apiUrl: `${origin}/api`, publicOrigin: origin, fetch: this.installFetch, timeoutMs: PLUGIN_INSTALL_MS });
|
|
443
|
+
}
|
|
421
444
|
loginFor(instanceUrl) {
|
|
422
445
|
if (this.deps.loginFor)
|
|
423
446
|
return this.deps.loginFor(instanceUrl);
|
|
@@ -520,7 +543,7 @@ export class OAuthProvider {
|
|
|
520
543
|
"X-Frame-Options": "DENY",
|
|
521
544
|
"X-Content-Type-Options": "nosniff",
|
|
522
545
|
"Referrer-Policy": "no-referrer",
|
|
523
|
-
"Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'unsafe-inline'; ${nonce ? `script-src 'nonce-${nonce}'; connect-src 'self'; ` : ""}form-action 'self'${formOrigin ? ` ${formOrigin}` : ""}; frame-ancestors 'none'; base-uri 'none'`,
|
|
546
|
+
"Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src 'self'; ${nonce ? `script-src 'nonce-${nonce}'; connect-src 'self'; ` : ""}form-action 'self'${formOrigin ? ` ${formOrigin}` : ""}; frame-ancestors 'none'; base-uri 'none'`,
|
|
524
547
|
});
|
|
525
548
|
res.end(body);
|
|
526
549
|
return true;
|
|
@@ -640,7 +663,7 @@ export class OAuthProvider {
|
|
|
640
663
|
return this.html(res, 429, errorPage("Too many requests", "Please wait a minute and try again."));
|
|
641
664
|
if ((await this.store.countPending()) >= MAX_PENDING)
|
|
642
665
|
return this.html(res, 503, errorPage("Busy", "Too many sign-ins in progress. Try again shortly."));
|
|
643
|
-
const asked = (q.get("scope") ?? "").split(/\s+/).filter(isScope);
|
|
666
|
+
const asked = (q.get("scope") ?? "").split(/\s+/).map(normalizeScope).filter(isScope);
|
|
644
667
|
const requestedMax = asked.length ? levelOf(asked) : "paperclip:read";
|
|
645
668
|
const rid = randomToken(24);
|
|
646
669
|
const p = { clientId: client.id, clientName: client.name, redirectUri, state, codeChallenge: challenge, requestedMax, csrf: randomToken(24), attempts: 0, expiresAt: this.now() + PENDING_TTL_MS };
|
|
@@ -676,7 +699,7 @@ export class OAuthProvider {
|
|
|
676
699
|
clientName: p.clientName,
|
|
677
700
|
redirectHost: u.host,
|
|
678
701
|
loopbackOnly: u.protocol === "http:" && LOOPBACK.has(u.hostname),
|
|
679
|
-
requestedMax: p.requestedMax,
|
|
702
|
+
requestedMax: normalizeScope(p.requestedMax),
|
|
680
703
|
login: this.deps.oauth.login,
|
|
681
704
|
approvalUrl: ch?.approvalUrl,
|
|
682
705
|
approved,
|
|
@@ -716,7 +739,7 @@ export class OAuthProvider {
|
|
|
716
739
|
await this.event("connect_failed", null, "denied");
|
|
717
740
|
return this.redirectWith(res, p.redirectUri, { error: "access_denied", error_description: "The user denied the request", state: p.state });
|
|
718
741
|
}
|
|
719
|
-
const allowed =
|
|
742
|
+
const allowed = grantableScopes();
|
|
720
743
|
const level = (f.get("level") ?? "");
|
|
721
744
|
if (!allowed.includes(level))
|
|
722
745
|
return this.renderConsent(res, rid, p, false, "Choose an access level.");
|
|
@@ -782,7 +805,7 @@ export class OAuthProvider {
|
|
|
782
805
|
const c = this.ctx(rid, p, error);
|
|
783
806
|
switch (p.stage) {
|
|
784
807
|
case "instance":
|
|
785
|
-
return this.page(res, status, instancePage({ ...c, value: extra.value, notice: extra.notice }), p);
|
|
808
|
+
return this.page(res, status, instancePage({ ...c, value: extra.value, notice: extra.notice, installPlugin: p.installPlugin }), p);
|
|
786
809
|
case "approve": {
|
|
787
810
|
const ch = this.unsealChallenge(p);
|
|
788
811
|
if (!ch || !p.instanceUrl) {
|
|
@@ -805,7 +828,7 @@ export class OAuthProvider {
|
|
|
805
828
|
const link = p.accountId ? await this.store.getLink(p.accountId) : undefined;
|
|
806
829
|
const u = new URL(p.redirectUri);
|
|
807
830
|
const acct = p.accountId ? await this.store.getAccount(p.accountId) : undefined;
|
|
808
|
-
return this.page(res, status, scopePage({ ...c, requestedMax: p.requestedMax, loopbackOnly: u.protocol === "http:" && LOOPBACK.has(u.hostname), instanceHost: link ? new URL(link.instanceUrl).host : "your Paperclip", username: p.username ?? "", anonymous: !!acct?.anonymous, alias: acct?.alias ?? null
|
|
831
|
+
return this.page(res, status, scopePage({ ...c, requestedMax: normalizeScope(p.requestedMax), loopbackOnly: u.protocol === "http:" && LOOPBACK.has(u.hostname), instanceHost: link ? new URL(link.instanceUrl).host : "your Paperclip", username: p.username ?? "", anonymous: !!acct?.anonymous, alias: acct?.alias ?? null }), p);
|
|
809
832
|
}
|
|
810
833
|
default:
|
|
811
834
|
return this.page(res, status, choosePage(c), p);
|
|
@@ -941,6 +964,7 @@ export class OAuthProvider {
|
|
|
941
964
|
return this.html(res, 429, errorPage("Too many attempts", "Too many addresses tried. Start again from the app."));
|
|
942
965
|
}
|
|
943
966
|
p.attempts += 1;
|
|
967
|
+
p.installPlugin = f.get("install_plugin") === "on";
|
|
944
968
|
const typed = (f.get("instance") ?? "").slice(0, 300);
|
|
945
969
|
const fail = async (why, detail) => {
|
|
946
970
|
await this.store.putPending(rid, p);
|
|
@@ -1007,6 +1031,8 @@ export class OAuthProvider {
|
|
|
1007
1031
|
}
|
|
1008
1032
|
if (!userId)
|
|
1009
1033
|
return this.renderStage(res, rid, p, "Paperclip did not say who you are, so we can't link a username to it.");
|
|
1034
|
+
if (p.installPlugin)
|
|
1035
|
+
this.installPluginLater(p, ch.boardApiToken);
|
|
1010
1036
|
const owner = await this.store.getLinkByIdentity(p.instanceUrl, userId);
|
|
1011
1037
|
if (p.accountId) {
|
|
1012
1038
|
// Reconnecting an account the user already signed in to with their secret key.
|
|
@@ -1140,7 +1166,7 @@ export class OAuthProvider {
|
|
|
1140
1166
|
if (!s)
|
|
1141
1167
|
return true;
|
|
1142
1168
|
const { f, p, rid } = s;
|
|
1143
|
-
const allowed = grantableScopes(
|
|
1169
|
+
const allowed = grantableScopes();
|
|
1144
1170
|
const level = (f.get("level") ?? "");
|
|
1145
1171
|
if (!allowed.includes(level))
|
|
1146
1172
|
return this.renderStage(res, rid, p, "Choose an access level.");
|
|
@@ -1156,13 +1182,6 @@ export class OAuthProvider {
|
|
|
1156
1182
|
if (want !== !!acct.anonymous)
|
|
1157
1183
|
acct = await this.setPrivacy(acct, want);
|
|
1158
1184
|
}
|
|
1159
|
-
if (f.get("beta_present") === "1") {
|
|
1160
|
-
const want = f.get("beta") === "on";
|
|
1161
|
-
if (want !== !!acct.beta) {
|
|
1162
|
-
await this.manageSetBeta(acct, want);
|
|
1163
|
-
acct = { ...acct, beta: want };
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
1185
|
const code = `pcb_ac_${randomToken(32)}`;
|
|
1167
1186
|
await this.store.putCode(sha256Hex(code), {
|
|
1168
1187
|
clientId: p.clientId,
|
|
@@ -1174,7 +1193,7 @@ export class OAuthProvider {
|
|
|
1174
1193
|
instanceUrl: link.instanceUrl,
|
|
1175
1194
|
sealedCredential: null, // the credential stays on the account link
|
|
1176
1195
|
accountId: p.accountId,
|
|
1177
|
-
username: displayName(acct), // grants carry the DISPLAY name: that is what audit rows and the
|
|
1196
|
+
username: displayName(acct), // grants carry the DISPLAY name: that is what audit rows and the operator dashboard see
|
|
1178
1197
|
path: p.path ?? "connect",
|
|
1179
1198
|
expiresAt: this.now() + CODE_TTL_MS,
|
|
1180
1199
|
});
|
|
@@ -1246,7 +1265,7 @@ export class OAuthProvider {
|
|
|
1246
1265
|
}
|
|
1247
1266
|
if (f.get("client_id") !== grant.clientId)
|
|
1248
1267
|
throw new OAuthError("invalid_grant", "client_id mismatch");
|
|
1249
|
-
const asked = (f.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
1268
|
+
const asked = (f.get("scope") ?? "").split(/\s+/).filter(Boolean).map(normalizeScope);
|
|
1250
1269
|
if (asked.some((s) => !isScope(s) || !scopeAllows(grant.scopes, s)))
|
|
1251
1270
|
throw new OAuthError("invalid_scope", "Requested scope exceeds the original grant");
|
|
1252
1271
|
return this.issue(grant);
|
|
@@ -1257,7 +1276,7 @@ export class OAuthProvider {
|
|
|
1257
1276
|
const refresh = `pcb_rt_${randomToken(32)}`;
|
|
1258
1277
|
await this.store.putAccess(sha256Hex(access), { grantId: grant.id, expiresAt: t + this.deps.oauth.accessTtlSec * 1000 });
|
|
1259
1278
|
await this.store.putRefresh(sha256Hex(refresh), { grantId: grant.id, expiresAt: t + this.deps.oauth.refreshTtlSec * 1000 });
|
|
1260
|
-
return { access_token: access, token_type: "Bearer", expires_in: this.deps.oauth.accessTtlSec, refresh_token: refresh, scope: grant.scopes.join(" ") };
|
|
1279
|
+
return { access_token: access, token_type: "Bearer", expires_in: this.deps.oauth.accessTtlSec, refresh_token: refresh, scope: normalizeScopes(grant.scopes).join(" ") };
|
|
1261
1280
|
}
|
|
1262
1281
|
// ───────────── POST /revoke (RFC 7009) ─────────────
|
|
1263
1282
|
async revoke(req, res) {
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Two levels: Read only and Full control. Full control is every tool, because the point of Papercliped is to run your
|
|
3
|
+
* Paperclip from anywhere, not just to watch it.
|
|
4
|
+
*/
|
|
5
|
+
export const SCOPES = ["paperclip:read", "paperclip:control"];
|
|
6
|
+
/** A retired third level. Old grants and apps that still ask for it get Full control. */
|
|
7
|
+
export const LEGACY_ADMIN = "paperclip:admin";
|
|
8
|
+
const RANK = { "paperclip:read": 1, "paperclip:control": 2 };
|
|
5
9
|
export const isScope = (s) => SCOPES.includes(s);
|
|
6
|
-
|
|
10
|
+
export const normalizeScope = (s) => (s === LEGACY_ADMIN ? "paperclip:control" : s);
|
|
11
|
+
/** Known scopes only, retired names mapped, no duplicates. */
|
|
12
|
+
export const normalizeScopes = (list) => [...new Set(list.map(normalizeScope).filter(isScope))];
|
|
13
|
+
/** read < control; control includes read. */
|
|
7
14
|
export function maxRank(granted) {
|
|
8
|
-
return Math.max(0, ...granted
|
|
15
|
+
return Math.max(0, ...normalizeScopes(granted).map((s) => RANK[s]));
|
|
9
16
|
}
|
|
17
|
+
/** Reads (and raw GETs) need Read only; anything that changes something needs Full control. */
|
|
10
18
|
export function requiredScope(tool, input = {}) {
|
|
11
19
|
if (tool.name === "paperclip_api_request")
|
|
12
|
-
return input.method === "GET" ? "paperclip:read" : "paperclip:
|
|
13
|
-
|
|
14
|
-
return "paperclip:admin";
|
|
15
|
-
return tool.access === "write" ? "paperclip:control" : "paperclip:read";
|
|
20
|
+
return input.method === "GET" ? "paperclip:read" : "paperclip:control";
|
|
21
|
+
return tool.access === "read" ? "paperclip:read" : "paperclip:control";
|
|
16
22
|
}
|
|
17
23
|
export const scopeAllows = (granted, needed) => maxRank(granted) >= RANK[needed];
|
|
18
|
-
/**
|
|
24
|
+
/** The scope list for a level, e.g. control → [read, control]. */
|
|
19
25
|
export const scopesUpTo = (level) => SCOPES.filter((s) => RANK[s] <= RANK[level]);
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
* is the one granting it; Admin is only offered when the app itself asked for it.
|
|
23
|
-
*/
|
|
24
|
-
export const grantableScopes = (requestedMax) => scopesUpTo(RANK[requestedMax] >= RANK["paperclip:control"] ? requestedMax : "paperclip:control");
|
|
26
|
+
/** Levels the person may choose: always both, because the person is the one granting it. */
|
|
27
|
+
export const grantableScopes = (_requestedMax) => [...SCOPES];
|
package/dist/openapi.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 } =
|
|
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
|
+
}
|