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.
Files changed (84) 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 +70 -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 +32 -18
  16. package/dist/oauth/paperclip-login.js +38 -0
  17. package/dist/oauth/pg-store.js +0 -13
  18. package/dist/oauth/provider.js +83 -64
  19. package/dist/oauth/scopes.js +19 -16
  20. package/dist/openapi.js +19 -2
  21. package/dist/public-api/health.js +46 -0
  22. package/dist/public-api/openapi.js +51 -0
  23. package/dist/public-api/routes.js +251 -0
  24. package/dist/public-api/types.js +14 -0
  25. package/dist/server.js +4 -0
  26. package/dist/site/landing.js +3 -3
  27. package/dist/site/markdown.js +2 -1
  28. package/dist/site/site.js +16 -4
  29. package/dist/telemetry/recorder.js +1 -1
  30. package/dist/tools.js +48 -2
  31. package/dist/web/csp.js +16 -0
  32. package/dist/web/hosts.js +23 -0
  33. package/dist/web/routes.js +132 -0
  34. package/docs/ARCHITECTURE.md +74 -73
  35. package/docs/COMMUNITY.md +86 -0
  36. package/docs/DEPLOY-RENDER.md +3 -3
  37. package/docs/DOMAINS.md +74 -0
  38. package/docs/LAUNCH.md +50 -34
  39. package/docs/RELEASING.md +37 -0
  40. package/docs/SECURITY.md +82 -65
  41. package/docs/SITEMAP.md +92 -0
  42. package/docs/V3-SUBDOMAINS.md +106 -0
  43. package/docs/chatgpt.md +20 -20
  44. package/docs/least-privilege.sql +23 -23
  45. package/docs/legal/PRIVACY.md +37 -37
  46. package/docs/legal/TERMS.md +11 -11
  47. package/docs/oauth.md +61 -62
  48. package/docs/supabase-schema.sql +8 -1
  49. package/migrations/001_init.sql +84 -84
  50. package/migrations/002_audit.sql +41 -41
  51. package/migrations/003_accounts.sql +66 -66
  52. package/migrations/004_privacy_panel.sql +57 -57
  53. package/migrations/006_two_levels.sql +5 -0
  54. package/package.json +10 -14
  55. package/site/docs/chatgpt.md +1 -1
  56. package/site/docs/cli.md +27 -0
  57. package/site/docs/connect-your-paperclip.md +120 -0
  58. package/site/docs/environment.md +64 -0
  59. package/site/docs/faq.md +31 -0
  60. package/site/docs/getting-started.md +11 -5
  61. package/site/docs/limits-and-errors.md +32 -0
  62. package/site/docs/manage-api.md +33 -0
  63. package/site/docs/manage.md +12 -21
  64. package/site/docs/other-ai-apps.md +28 -0
  65. package/site/docs/paperclip-plugin.md +36 -0
  66. package/site/docs/permissions.md +5 -5
  67. package/site/docs/public-api.md +62 -0
  68. package/site/docs/security.md +2 -2
  69. package/site/docs/self-hosting.md +43 -0
  70. package/site/docs/signup.md +3 -3
  71. package/site/docs/tools.md +47 -0
  72. package/site/docs/troubleshooting.md +2 -2
  73. package/site/docs/what-is-papercliped.md +37 -0
  74. package/site/privacy.md +5 -3
  75. package/site/terms.md +1 -1
  76. package/skills/paperclip/SKILL.md +23 -23
  77. package/dist/demo.js +0 -144
  78. package/dist/manage/page.js +0 -170
  79. package/dist/panel/config.js +0 -61
  80. package/dist/panel/main.js +0 -90
  81. package/dist/panel/page.js +0 -553
  82. package/dist/panel/routes.js +0 -284
  83. package/dist/panel/server.js +0 -37
  84. package/docs/panel-role.sql +0 -26
@@ -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 panel show for an account: its alias if it opted into anonymity, else its username. */
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 beta connection manager) ─────────────
226
- // Everything here acts on ONE account and is reached only through ManageRoutes after the owner proved the secret key.
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
- /** Key for signing manager session cookies (derived from the service secret; never the secret itself). */
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 single-use code the user types into the Paperclip plugin. Valid 10 minutes. */
260
- async manageIssueLinkCode(a) {
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 manageAccountForPluginToken(token) {
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. The owner may move it between read and control, or lower an admin grant; admin is never granted here. */
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 = scopesUpTo(p.requestedMax);
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, beta: !!acct?.beta }), p);
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(p.requestedMax);
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 panel see
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) {
@@ -1,24 +1,27 @@
1
- export const SCOPES = ["paperclip:read", "paperclip:control", "paperclip:admin"];
2
- const RANK = { "paperclip:read": 1, "paperclip:control": 2, "paperclip:admin": 3 };
3
- /** Tools that are `write` by access class but too consequential for the control tier. */
4
- const ADMIN_TOOLS = new Set(["paperclip_decide_approval", "paperclip_set_agent_budget"]);
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
- /** read < control < admin; each level includes the ones below. */
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.filter(isScope).map((s) => RANK[s]));
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:admin";
13
- if (tool.access === "destructive" || ADMIN_TOOLS.has(tool.name))
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
- /** Highest-level scope name for a rank, e.g. 2 → paperclip:control. */
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
- * Levels the person may choose in the account flow. Control ("Full control (beta)") is always on offer, because the person
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 { 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
+ }