papercliped 2.1.0 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Run your Paperclip from Claude: control agents, sync with them and get reports, at the access level you choose.",
5
5
  "author": {
6
6
  "name": "KpnWorld"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v2.2.0 — install the Papercliped plugin while connecting
6
+
7
+ - **The Papercliped plugin can install itself while you connect.** The "Connect your Paperclip" step has a new box, ticked by default: *Also install the Papercliped plugin in my Paperclip*. After you approve the sign-in, Papercliped asks your Paperclip to install `papercliped-paperclip-plugin` through Paperclip's own installer, using your own approved key. It installs one fixed package at the exact version that matches the service, only if you are the instance admin, and does nothing if the plugin is already there, you are not an admin, or your Paperclip has no plugin support. It never delays or fails your connection, and the outcome is logged as a short `plugin` event (installed, already, denied, unsupported or failed). Untick the box to skip it; installing by hand from Settings → Plugins still works.
8
+ - **Docs tidy-up:** the OAuth flow diagram and the security notes no longer mention a third "Admin" level (there are exactly two: Read only and Full control), and the repository ignores generated files (`openapi.json`, `.wrangler/`, coverage output) so they can't be committed by accident.
9
+ - **Fixed:** the website’s changelog date test only allowed 5–6 October 2026 and would have failed every later release; it now accepts any real date between the first release and today. The package check (`scripts/check-packages.mjs`) also runs on Windows now.
10
+
5
11
  ## v2.1.0 — two access levels, manage in Paperclip, new website
6
12
 
7
13
  - **Dependencies up to date:** Zod 4 (tool schemas and the ChatGPT Actions OpenAPI now come from Zod's built-in JSON Schema; the published contract is unchanged), the latest MCP SDK, undici 8, TypeScript 7, Vitest 5, React 19 and esbuild 0.28 in the plugin, and GitHub Actions v7. No known vulnerabilities in either package. Dependabot now also watches the website and groups minor and patch updates into one pull request per package.
@@ -86,6 +86,10 @@ export function instancePage(v) {
86
86
  <label class="f" for="inst">Paperclip address</label>
87
87
  <input id="inst" name="instance" type="text" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://paperclip.example.com" value="${esc(v.value ?? "")}" required>
88
88
  <small class="hint">The bridge contacts this address to sign you in and to run what you allow. Only enter an address you own.</small>
89
+ <label class="f" style="display:flex;gap:10px;align-items:flex-start;margin-top:16px;font-weight:400">
90
+ <input id="plug" name="install_plugin" type="checkbox" value="on" style="width:auto;margin-top:3px"${v.installPlugin === false ? "" : " checked"}>
91
+ <span>Also install the Papercliped plugin in my Paperclip<br><small class="hint">Adds a Papercliped page to Paperclip for managing connected apps. It is installed through Paperclip's own installer, only if you are its instance admin, and you can remove it any time from Settings &rarr; Plugins.</small></span>
92
+ </label>
89
93
  <div class="row"><button class="btn" name="action" value="continue">Continue</button>${cancel}</div>
90
94
  </form>
91
95
  <p class="fine">You'll return to <code>${esc(v.redirectHost)}</code> afterwards.</p>`);
@@ -1,5 +1,7 @@
1
1
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
2
  const APPROVAL_PATH = /^\/cli-auth\/[0-9a-f-]{36}\?token=[A-Za-z0-9_\-.~%]{8,300}$/i;
3
+ /** Paperclip runs npm install server-side, which can take a while. */
4
+ const PLUGIN_INSTALL_TIMEOUT_MS = 120_000;
3
5
  const TOKENISH = /^[\x21-\x7e]{8,600}$/; // printable ASCII, no whitespace
4
6
  export class LoginError extends Error {
5
7
  status;
@@ -80,6 +82,42 @@ export class PaperclipLogin {
80
82
  const r = await json(await this.t.fetch(`${this.t.apiUrl}/cli-auth/me`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, ...this.sig() }));
81
83
  return { userId: typeof r?.userId === "string" ? r.userId.slice(0, 200) : null };
82
84
  }
85
+ /**
86
+ * The Papercliped plugin's state in this Paperclip: absent, or installed with its version.
87
+ * `unsupported` = no plugin system there (older Paperclip) or the key may not manage plugins.
88
+ */
89
+ async pluginState(token, pluginKey) {
90
+ const res = await this.t.fetch(`${this.t.apiUrl}/plugins`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, ...this.sig() });
91
+ if (res.status === 403 || res.status === 404)
92
+ return { state: "unsupported" };
93
+ const list = await json(res);
94
+ if (!Array.isArray(list))
95
+ throw new LoginError("Paperclip returned an unexpected plugin list");
96
+ const hit = list.find((x) => x && typeof x === "object" && x.pluginKey === pluginKey);
97
+ if (!hit)
98
+ return { state: "absent" };
99
+ return { state: "installed", version: typeof hit.version === "string" ? hit.version.slice(0, 40) : null };
100
+ }
101
+ /**
102
+ * Install one fixed npm package at one exact version through Paperclip's own installer. Only an instance
103
+ * admin's key is accepted there; anyone else gets a 403, reported as "denied". Nothing user-supplied reaches this call.
104
+ */
105
+ async installPlugin(token, packageName, version) {
106
+ const res = await this.t.fetch(`${this.t.apiUrl}/plugins/install`, {
107
+ method: "POST",
108
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
109
+ body: JSON.stringify({ packageName, version }),
110
+ signal: AbortSignal.timeout(Math.max(this.t.timeoutMs, PLUGIN_INSTALL_TIMEOUT_MS)),
111
+ });
112
+ if (res.status === 403)
113
+ return "denied";
114
+ if (res.status === 404)
115
+ return "unsupported";
116
+ const out = await json(res);
117
+ if (out?.packageName !== packageName)
118
+ throw new LoginError("Paperclip installed something other than the Papercliped plugin");
119
+ return "installed";
120
+ }
83
121
  /** Best effort: invalidate the board key we were issued when a grant is revoked. */
84
122
  async revoke(token) {
85
123
  try {
@@ -8,6 +8,7 @@ import { UnsafeUrlError, createSafeFetch, parseInstanceUrl } from "../net/safe-f
8
8
  import { Keyring, isValidCodeChallenge, randomToken, safeEqual, sha256Hex, verifyPkce } from "./crypto.js";
9
9
  import { approvePage, choosePage, consentPage, errorPage, instancePage, scopePage, secretPage, usernamePage, welcomePage } from "./pages.js";
10
10
  import { LoginError, PaperclipLogin } from "./paperclip-login.js";
11
+ import { VERSION } from "../version.js";
11
12
  import { MemoryStore } from "./store.js";
12
13
  import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
13
14
  const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
@@ -15,6 +16,12 @@ const PENDING_TTL_MS = 10 * 60_000;
15
16
  const CODE_TTL_MS = 60_000;
16
17
  const MAX_PENDING = 5000;
17
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;
18
25
  const MAX_FORM_BYTES = 16 * 1024;
19
26
  const DAY_MS = 24 * 3600 * 1000;
20
27
  class OAuthError extends Error {
@@ -58,6 +65,10 @@ export class OAuthProvider {
58
65
  keyring;
59
66
  now;
60
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();
61
72
  multi;
62
73
  accounts;
63
74
  constructor(deps) {
@@ -68,6 +79,7 @@ export class OAuthProvider {
68
79
  this.multi = deps.oauth.mode === "multi";
69
80
  this.accounts = deps.oauth.accounts && this.multi;
70
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 });
71
83
  this.resource = `${deps.oauth.issuer}/mcp`;
72
84
  this.metadataUrl = `${deps.oauth.issuer}/.well-known/oauth-protected-resource`;
73
85
  }
@@ -389,6 +401,46 @@ export class OAuthProvider {
389
401
  throw new UnsafeUrlError("This service is not open to that host yet.");
390
402
  return u;
391
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
+ }
392
444
  loginFor(instanceUrl) {
393
445
  if (this.deps.loginFor)
394
446
  return this.deps.loginFor(instanceUrl);
@@ -753,7 +805,7 @@ export class OAuthProvider {
753
805
  const c = this.ctx(rid, p, error);
754
806
  switch (p.stage) {
755
807
  case "instance":
756
- 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);
757
809
  case "approve": {
758
810
  const ch = this.unsealChallenge(p);
759
811
  if (!ch || !p.instanceUrl) {
@@ -912,6 +964,7 @@ export class OAuthProvider {
912
964
  return this.html(res, 429, errorPage("Too many attempts", "Too many addresses tried. Start again from the app."));
913
965
  }
914
966
  p.attempts += 1;
967
+ p.installPlugin = f.get("install_plugin") === "on";
915
968
  const typed = (f.get("instance") ?? "").slice(0, 300);
916
969
  const fail = async (why, detail) => {
917
970
  await this.store.putPending(rid, p);
@@ -978,6 +1031,8 @@ export class OAuthProvider {
978
1031
  }
979
1032
  if (!userId)
980
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);
981
1036
  const owner = await this.store.getLinkByIdentity(p.instanceUrl, userId);
982
1037
  if (p.accountId) {
983
1038
  // Reconnecting an account the user already signed in to with their secret key.
package/docs/SECURITY.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Read this before running the bridge for people other than yourself.
4
4
 
5
5
  ## What you are holding
6
- For every connected user the bridge stores a **Paperclip board API key**. In Paperclip that key is bound to the *user*, not to a company, and carries **all** of that user's board power (verified in Paperclip's source: `board_api_keys` has no scope column, and the challenge's `requestedCompanyId` is only used to attribute activity-log entries, not to restrict the key). The bridge's scopes (read/control/admin) limit what *connected AI clients* may do through the bridge. They do **not** limit what the stored key can do if the bridge itself is compromised.
6
+ For every connected user the bridge stores a **Paperclip board API key**. In Paperclip that key is bound to the *user*, not to a company, and carries **all** of that user's board power (verified in Paperclip's source: `board_api_keys` has no scope column, and the challenge's `requestedCompanyId` is only used to attribute activity-log entries, not to restrict the key). The bridge's two access levels (Read only and Full control) limit what *connected AI clients* may do through the bridge. They do **not** limit what the stored key can do if the bridge itself is compromised.
7
7
 
8
8
  > **Headline risk:** a full compromise of the running bridge (code execution on the Render service *and* access to its environment) exposes every connected user's Paperclip with full board access, until those keys are revoked. Everything below reduces the likelihood or blast radius; none of it removes that risk. Until Paperclip offers scoped/short-lived keys, tell users plainly, encourage a dedicated Paperclip user for the connection, and keep the user base small enough to be comfortable with that.
9
9
 
package/docs/oauth.md CHANGED
@@ -7,7 +7,7 @@ client ──401 + resource_metadata──▶ bridge /mcp
7
7
  client ──DCR /register──────────────▶ bridge
8
8
  client ──/authorize (PKCE S256)─────▶ bridge ──create challenge──▶ Paperclip /api/cli-auth/challenges
9
9
  user ── opens approvalUrl, signs in to Paperclip, approves ─────▶ Paperclip
10
- user ── picks Read / Control / Admin, presses Allow ───────────▶ bridge (verifies approval, checks token)
10
+ user ── picks Read only / Full control, presses Allow ────────▶ bridge (verifies approval, checks token)
11
11
  client ──/token (code + verifier)───▶ bridge ──▶ opaque access + rotating refresh token
12
12
  client ──/mcp Bearer pcb_at_…──────▶ bridge ──user's board token──▶ Paperclip
13
13
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Papercliped — connect Claude, ChatGPT and any MCP app to your Paperclip: control agents, sync, report. MCP server + hosted multi-tenant OAuth bridge + public stats API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,7 +5,11 @@ The Papercliped plugin is where you manage Papercliped, right inside Paperclip:
5
5
  It manages your Papercliped account only. It never reads or controls your Paperclip itself, and AI apps still connect through Papercliped as before.
6
6
 
7
7
  ## Install (Paperclip admin)
8
- Install the npm package `papercliped-paperclip-plugin` from **Settings → Plugins** in Paperclip. Installing is done by the person who runs the Paperclip instance, never remotely. The plugin asks for these capabilities: store its own data, make outbound web requests (to Papercliped), and add a sidebar entry and a page.
8
+ **Automatic (recommended).** When you connect your Paperclip from an AI app, the address step has a box, ticked by default: **Also install the Papercliped plugin in my Paperclip**. After you approve the sign-in, Papercliped asks your Paperclip to install it using Paperclip's own installer and your own approved key. It installs one fixed package, `papercliped-paperclip-plugin`, at the exact version that matches the service, and only when you are the Paperclip's instance admin. If you are not an admin, if it is already installed, or if your Paperclip can't install plugins, nothing happens and your connection works as usual. Untick the box to skip it.
9
+
10
+ **By hand.** Install the npm package `papercliped-paperclip-plugin` from **Settings → Plugins** in Paperclip (instance admin only). You can remove it there at any time, whichever way it was installed.
11
+
12
+ The plugin asks for these capabilities: store its own data, make outbound web requests (to Papercliped), and add a sidebar entry and a page.
9
13
 
10
14
  The bridge address defaults to `https://papercliped.co`. If you host your own bridge, change **Papercliped bridge URL** in the plugin's settings. It must be `https`.
11
15
 
@@ -27,6 +31,6 @@ Lost your secret key? Connect from your AI app again and choose **Connect your P
27
31
  - **Delete my account.** Removes your username, stored key and every connection. Type your username to confirm.
28
32
 
29
33
  ## Good to know
30
- - The plugin runs as trusted code inside Paperclip, so install it only from the official package.
34
+ - The plugin runs as trusted code inside Paperclip, so install it only from the official package. The automatic install is limited to that one package and to the version that matches the service, and it is always your choice on the connect screen.
31
35
  - The link token stays on your Paperclip server and is never shown on the page. It can only manage your account: it can't call any Paperclip tool.
32
36
  - There is no separate website page for this any more; the old `/manage` address points here.