papercliped 2.1.0 → 2.2.1

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.1",
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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v2.2.1 — the Papercliped plugin now installs in Paperclip
6
+
7
+ - **Fixed: the Papercliped plugin could not be installed in Paperclip.** Paperclip rejected it with a bare "400" because the sidebar entry declared a page route, which Paperclip only allows on pages. Installing it, automatically while connecting or by hand from Settings → Plugins, now works. The plugin’s tests now run Paperclip’s own manifest check so this can’t come back. If you ticked the box while connecting on 2.2.0, nothing was installed: connect again, or install it from Settings → Plugins.
8
+
9
+ ## v2.2.0 — install the Papercliped plugin while connecting
10
+
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+
5
15
  ## v2.1.0 — two access levels, manage in Paperclip, new website
6
16
 
7
17
  - **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.
@@ -0,0 +1,84 @@
1
+ # Onboarding: how each protocol step works
2
+
3
+ Notes on what happens from "add the connector" to "the AI can call tools", with the exact exchange at each step.
4
+ **Observed** = captured from the live service (`https://mcp.papercliped.co`, v2.2.0, 2026-10-07).
5
+ **From code** = read from `src/` but not exercised live in this pass (it needs a real Paperclip sign-in).
6
+
7
+ ## The whole flow
8
+
9
+ ```
10
+ AI app Papercliped bridge the user's Paperclip
11
+ | 1 POST /mcp (no token) ------>| |
12
+ |<----- 401 + WWW-Authenticate --| |
13
+ | 2 GET protected-resource ---->| (where is the login server?) |
14
+ | 3 GET authorization-server -->| (what endpoints, what is supported?) |
15
+ | 4 POST /register ------------->| (dynamic client registration) |
16
+ | 5 open /authorize in browser ->| (PKCE, S256) ... sign-in pages ... |
17
+ | |-- 6 probe GET /api/health -------------->|
18
+ | |-- 7 POST /api/cli-auth/challenges ------>|
19
+ | user approves in Paperclip's own UI <-----------------------------|
20
+ | |-- 8 poll challenge, GET /cli-auth/me --->|
21
+ |<-- 9 redirect with ?code=... ---| |
22
+ | 10 POST /token (code+verifier)->| |
23
+ |<----- access + refresh token ---| |
24
+ | 11 POST /mcp Bearer token ---->|-- 12 tool call with the user's key ----->|
25
+ ```
26
+
27
+ ## 1. Unauthenticated call is answered with a pointer (Observed)
28
+ `POST /mcp` with no token returns `401` and
29
+
30
+ `WWW-Authenticate: Bearer resource_metadata="https://mcp.papercliped.co/.well-known/oauth-protected-resource", scope="paperclip:read"`
31
+
32
+ This is the MCP authorization spec's discovery trigger: the client learns where to read the resource metadata. The `scope` hint is the minimum (read).
33
+
34
+ ## 2. Protected-resource metadata (Observed)
35
+ `GET /.well-known/oauth-protected-resource` returns the resource (`https://mcp.papercliped.co/mcp`), its one authorization server (`https://mcp.papercliped.co`), the scopes `paperclip:read` and `paperclip:control`, and `bearer_methods_supported: ["header"]`.
36
+ The connector URL a user pastes must equal `<BRIDGE_PUBLIC_URL>/mcp`, because the issuer is baked into every token (changing it logs everyone out).
37
+
38
+ ## 3. Authorization-server metadata (Observed)
39
+ `GET /.well-known/oauth-authorization-server` lists `/authorize`, `/token`, `/register`, `/revoke`; response type `code` only; grants `authorization_code` and `refresh_token`; PKCE method `S256` only; token endpoint auth `none` (public clients); scopes `paperclip:read`, `paperclip:control`, `offline_access`; and `authorization_response_iss_parameter_supported: true` (the redirect carries `iss`, which defends against mix-up attacks).
40
+
41
+ ## 4. Dynamic client registration (Observed)
42
+ `POST /register` with a name and `redirect_uris` returns a `client_id` (`pcb_c_...`), no secret (public client), and the granted grant types and scopes.
43
+ Redirect URIs are checked against an allow-list of hosts (`claude.ai`, `claude.com`, `chatgpt.com`, plus loopback for local tools): a registration with another host is refused with `400`.
44
+
45
+ ## 5. /authorize and the sign-in pages (Observed first page, rest From code)
46
+ The client opens `/authorize?response_type=code&client_id&redirect_uri&code_challenge&code_challenge_method=S256&state&scope&resource`. The bridge stores the request as a short-lived pending record and answers with **"Sign in to Papercliped"**, which has two forms:
47
+ - **Log in** (`POST /authorize/login`): username + secret key (`pcs_...`), for returning users.
48
+ - **Connect your Paperclip** (`POST /authorize/connect`): for new users or lost keys.
49
+
50
+ Every page carries a one-time request id and a CSRF token; steps must arrive in order (an out-of-order post just re-shows the right page). The pages are plain server-rendered HTML with a strict CSP.
51
+
52
+ ### Connect path (From code, pages in `src/oauth/pages.ts`)
53
+ 1. **Connect your Paperclip** (`POST /authorize/instance`): the user types their Paperclip address (public `https`, port 443 unless allowed). New in 2.2.0: a box, ticked by default, "Also install the Papercliped plugin in my Paperclip". The address passes the SSRF guard (public addresses only, no redirects, size and time limits, at most 5 tries per request).
54
+ 2. **Probe**: `GET <paperclip>/api/health` must answer JSON with `status` and `deploymentMode: "authenticated"`. This is the step that failed on 2026-10-07 when the instance's database was down (the endpoint hung, which the bridge reports as "connection failed").
55
+ 3. **Challenge**: `POST <paperclip>/api/cli-auth/challenges` (Paperclip's own CLI-login flow) returns a challenge id, a secret, a pending board token and an approval link. The bridge validates every field before use.
56
+ 4. **Approve in Paperclip** (page "Approve in your Paperclip"): the user opens the approval link on their own Paperclip, signs in there (the bridge never sees that password) and approves. The page polls `/authorize/status`.
57
+ 5. **Approved** (`POST /authorize/approved`): the bridge checks the challenge is approved, then `GET /api/cli-auth/me` with the board token to learn the Paperclip user id. If that Paperclip user already has a Papercliped account the user is signed straight in. If the box was ticked, the plugin install starts in the background here (see below).
58
+ 6. **Username** (new users): choose a username (and optionally anonymity); the account is created.
59
+ 7. **Secret key**: shown once. It signs the user in next time without Paperclip's approval.
60
+ 8. **Access level**: exactly two, **Read only** (`paperclip:read`) and **Full control** (`paperclip:control`, every tool). Read only is preselected unless the app asked for control.
61
+ 9. **Decision** (`POST /authorize/decision`): the bridge issues a one-use authorization code (valid 60 seconds) bound to the client, redirect URI, PKCE challenge and scopes, and redirects to the app with `?code=...&state=...&iss=...`.
62
+
63
+ ### Log-in path (From code)
64
+ Username + secret key replaces steps 1-5. If the stored Paperclip credential has expired, the bridge sends the user back through the connect path once ("Your Paperclip connection has expired").
65
+
66
+ ## 6. Plugin install during connect (From code, tested against a stand-in Paperclip)
67
+ Only if the box is ticked, after step 5 succeeds and without delaying the redirect:
68
+ 1. `GET <paperclip>/api/plugins` with the user's new board token; look for plugin key `papercliped.remote-control`.
69
+ 2. Already there: stop (event detail `already`). `403/404`: stop (`unsupported`).
70
+ 3. Otherwise `POST <paperclip>/api/plugins/install` with exactly `{ packageName: "papercliped-paperclip-plugin", version: <this bridge's version> }`. Paperclip allows it only for an instance admin (`403` otherwise, recorded as `denied`). The response's package name is checked.
71
+ Paperclip runs `npm install <pkg>@<version> --ignore-scripts` into its own plugins folder, then checks the plugin's manifest against its schema, the plugin API version, the capabilities, page-route conflicts and the minimum host version, and registers it. A failure at any of those is a bare `400` in Paperclip's log with no reason. (Observed 2026-10-07: 2.2.0 downloaded fine, then failed the schema because its sidebar slot declared a `routePath`. Fixed in 2.2.1; the plugin's tests now run Paperclip's own `pluginManifestV1Schema`.)
72
+ 4. One `plugin` event is recorded with a single word: `installed`, `already`, `denied`, `unsupported` or `failed`. Nothing here can fail the connection.
73
+
74
+ ## 7. Token exchange (Observed error shape, rest From code)
75
+ `POST /token` with `grant_type=authorization_code`, the code, `redirect_uri`, `client_id` and the PKCE `code_verifier`. A wrong or expired code returns `{"error":"invalid_grant","error_description":"Invalid or expired authorization code"}` (Observed). On success the bridge returns an opaque access token (`pcb_at_...`, 1 hour by default) and a rotating refresh token (30 days by default, `offline_access`). Tokens are stored only as hashes; the user's Paperclip key is stored sealed (encrypted) with the bridge secret.
76
+
77
+ ## 8. Calling tools (From code)
78
+ `POST /mcp` with `Authorization: Bearer pcb_at_...`. The bridge looks up the grant, decrypts the user's Paperclip key, checks the grant's scopes against the tool (read-only tools need `paperclip:read`; anything that changes state needs `paperclip:control`), calls that user's Paperclip `/api`, and writes one audit event (tool name, whether it changed anything, ok or a coarse error class, scope needed, timings, the caller's grant id, username and Paperclip host; never tool arguments or results). Each connection is rate-limited (120 calls a minute by default). A refresh token swaps for a new pair and is single-use: presenting an old one a second time is treated as theft and revokes the whole connection (`Refresh token already used`). A refresh can never ask for more than the original grant.
79
+
80
+ ## 9. Ending a connection (From code)
81
+ `POST /revoke`, disconnecting in the Papercliped plugin, or 30 days of inactivity revokes the grant, and the bridge asks the user's Paperclip to revoke the board key it was issued (`POST /api/cli-auth/revoke-current`, best effort).
82
+
83
+ ## What was not done in this pass
84
+ No account was created and no real sign-in was approved. The steps marked From code have not been exercised against a live Paperclip in this pass.
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.1",
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.