papercliped 2.5.0 → 2.5.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.5.0",
3
+ "version": "2.5.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,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v2.5.1 — Paperclip keys renew themselves
6
+
7
+ - **Connections no longer break after 30 days.** Newer Paperclip versions expire the key Papercliped gets when you approve the sign-in after 30 days. Papercliped now renews it about a week before: it asks your Paperclip for a fresh key (named "Papercliped (your username)", same 30-day expiry), stores it encrypted and revokes the old one. Keys of people who rarely use their connection are renewed by the periodic sweep. If a renewal fails, the current key keeps working and it is tried again later. Older Paperclip versions, whose keys don't expire, work as before. Self-hosters on Postgres: run the database migration before starting this version (`npm run migrate`, or `BRIDGE_AUTO_MIGRATE=1`); keys stored before it are assumed to expire 30 days after they were connected, and the first renewal check reads the real date from Paperclip.
8
+ - **Community cards.** Ready-to-post images for the launch and the new r/OpenSourcedd community, light and dark, 1600×900, in `web/public/brand/community/` (rebuilt by `web/scripts/community-cards.mjs`). Download them from papercliped.co/brand, and the README now opens with the launch card.
9
+
5
10
  ## v2.5.0 — icon pack, launch-ready docs and tester feedback
6
11
 
7
12
  - **Icon pack.** papercliped.co/brand has a one-click ZIP with every Papercliped icon in light and dark: the app icon from 16 to 1024 px, a favicon, profile pictures safe for circle crops (Discord, X, GitHub, npm, MCP directories), an Android/installable-app icon, an X header, a 16:9 banner and link-preview images. They're built from the real mascot and the site's own components by `web/scripts/icon-pack.mjs`, so they always match. The website now also has a classic favicon, an Apple touch icon and a web app manifest.
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Papercliped
2
2
 
3
+ <a href="https://papercliped.co">
4
+ <picture>
5
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/OpenSourcx/papercliped/main/web/public/brand/community/papercliped-launch-dark.png">
6
+ <img alt="Papercliped: run your Paperclip from any AI app. Claude, ChatGPT, Codex or any MCP app. Connector: https://mcp.papercliped.co/mcp" src="https://raw.githubusercontent.com/OpenSourcx/papercliped/main/web/public/brand/community/papercliped-launch-light.png">
7
+ </picture>
8
+ </a>
9
+
3
10
  **Run your [Paperclip](https://github.com/paperclipai/paperclip) from any AI app.** Papercliped connects Claude, ChatGPT, Codex and any MCP app to your Paperclip, the open-source orchestrator for companies of AI agents. Check on agents, pause or wake them, assign issues, decide approvals and get status, cost and activity reports, without opening the dashboard.
4
11
 
5
12
  [![CI](https://github.com/OpenSourcx/papercliped/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenSourcx/papercliped/actions/workflows/ci.yml)
@@ -3,6 +3,15 @@ const APPROVAL_PATH = /^\/cli-auth\/[0-9a-f-]{36}\?token=[A-Za-z0-9_\-.~%]{8,300
3
3
  /** Paperclip runs npm install server-side, which can take a while. */
4
4
  const PLUGIN_INSTALL_TIMEOUT_MS = 120_000;
5
5
  const TOKENISH = /^[\x21-\x7e]{8,600}$/; // printable ASCII, no whitespace
6
+ /** How long Paperclip keeps a board key it issued without an explicit expiry (its BOARD_API_KEY_TTL_MS). */
7
+ export const PAPERCLIP_KEY_TTL_MS = 30 * 24 * 3600 * 1000;
8
+ /** null = never expires; undefined = not said. Anything unreadable counts as not said. */
9
+ function expiryOf(v) {
10
+ if (v === null)
11
+ return null;
12
+ const t = typeof v === "string" ? Date.parse(v) : NaN;
13
+ return Number.isFinite(t) ? t : undefined;
14
+ }
6
15
  export class LoginError extends Error {
7
16
  status;
8
17
  constructor(message, status) {
@@ -118,6 +127,38 @@ export class PaperclipLogin {
118
127
  throw new LoginError("Paperclip installed something other than the Papercliped plugin");
119
128
  return "installed";
120
129
  }
130
+ /**
131
+ * The calling key's id and expiry, from Paperclip's own key listing. `unsupported` = this Paperclip has no key
132
+ * listing (versions from before board keys expired). expiresAt: null = never expires, undefined = not listed.
133
+ */
134
+ async keyInfo(token) {
135
+ const auth = { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } };
136
+ const me = await json(await this.t.fetch(`${this.t.apiUrl}/cli-auth/me`, { ...auth, ...this.sig() }));
137
+ const keyId = typeof me?.keyId === "string" && UUID.test(me.keyId) ? me.keyId : null;
138
+ const res = await this.t.fetch(`${this.t.apiUrl}/board-api-keys`, { ...auth, ...this.sig() });
139
+ if (res.status === 404)
140
+ return { state: "unsupported" };
141
+ const list = await json(res);
142
+ if (!Array.isArray(list))
143
+ throw new LoginError("Paperclip returned an unexpected key list");
144
+ const mine = keyId ? list.find((k) => k && typeof k === "object" && k.id === keyId) : undefined;
145
+ return { state: "ok", keyId, expiresAt: mine ? expiryOf(mine.expiresAt) : undefined };
146
+ }
147
+ /** A new board key for the same Paperclip user, with Paperclip's default expiry. `unsupported` = no key API there. */
148
+ async createKey(token, name) {
149
+ const res = await this.t.fetch(`${this.t.apiUrl}/board-api-keys`, {
150
+ method: "POST",
151
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
152
+ body: JSON.stringify({ name: name.slice(0, 120) }),
153
+ ...this.sig(),
154
+ });
155
+ if (res.status === 404)
156
+ return { state: "unsupported" };
157
+ const k = await json(res);
158
+ if (typeof k?.token !== "string" || !TOKENISH.test(k.token))
159
+ throw new LoginError("Paperclip returned an invalid credential");
160
+ return { state: "created", keyId: typeof k.id === "string" && UUID.test(k.id) ? k.id : null, token: k.token, expiresAt: expiryOf(k.expiresAt) };
161
+ }
121
162
  /** Best effort: invalidate the board key we were issued when a grant is revoked. */
122
163
  async revoke(token) {
123
164
  try {
@@ -23,6 +23,7 @@ const toGrant = (r) => ({
23
23
  resource: r.resource,
24
24
  instanceUrl: r.instance_url,
25
25
  sealedCredential: r.sealed_credential,
26
+ credentialExpiresAt: r.credential_expires_at == null ? null : num(r.credential_expires_at),
26
27
  accountId: r.account_id ?? null,
27
28
  username: r.username ?? null,
28
29
  createdAt: num(r.created_at),
@@ -69,9 +70,9 @@ export class PgStore {
69
70
  order by last_used_at desc offset $2))`, [t - CLIENT_IDLE_MS, MAX_CLIENTS]);
70
71
  }
71
72
  async putGrant(g) {
72
- await this.q(`insert into bridge.grants (id, client_id, client_name, user_id, scopes, resource, instance_url, sealed_credential, created_at, last_used_at, revoked, account_id, username)
73
- values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
74
- on conflict (id) do update set sealed_credential = excluded.sealed_credential, scopes = excluded.scopes, revoked = excluded.revoked, last_used_at = excluded.last_used_at`, [g.id, g.clientId, g.clientName, g.userId, g.scopes, g.resource, g.instanceUrl, g.sealedCredential, g.createdAt, g.lastUsedAt, g.revoked, g.accountId ?? null, g.username ?? null]);
73
+ await this.q(`insert into bridge.grants (id, client_id, client_name, user_id, scopes, resource, instance_url, sealed_credential, created_at, last_used_at, revoked, account_id, username, credential_expires_at)
74
+ values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
75
+ on conflict (id) do update set sealed_credential = excluded.sealed_credential, credential_expires_at = excluded.credential_expires_at, scopes = excluded.scopes, revoked = excluded.revoked, last_used_at = excluded.last_used_at`, [g.id, g.clientId, g.clientName, g.userId, g.scopes, g.resource, g.instanceUrl, g.sealedCredential, g.createdAt, g.lastUsedAt, g.revoked, g.accountId ?? null, g.username ?? null, g.credentialExpiresAt ?? null]);
75
76
  }
76
77
  async getGrant(id) {
77
78
  const r = await this.q("select * from bridge.grants where id = $1", [id]);
@@ -178,7 +179,7 @@ export class PgStore {
178
179
  return { id: x.id, username: x.username, usernameKey: x.username_key, secretHash: x.secret_hash, createdAt: num(x.created_at), lastLoginAt: x.last_login_at == null ? null : num(x.last_login_at), disabled: x.disabled, anonymous: !!x.anonymous, alias: x.alias ?? null, beta: !!x.beta, policy: x.policy ?? null };
179
180
  }
180
181
  static toLink(x) {
181
- return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
182
+ return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, credentialExpiresAt: x.credential_expires_at == null ? null : num(x.credential_expires_at), createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
182
183
  }
183
184
  async createAccount(a) {
184
185
  try {
@@ -283,8 +284,8 @@ export class PgStore {
283
284
  }
284
285
  async putLink(l) {
285
286
  try {
286
- await this.q(`insert into bridge.account_links (account_id, instance_url, paperclip_user_id, sealed_credential, created_at, connected_at, last_used_at, instance_label) values ($1,$2,$3,$4,$5,$6,$7,$8)
287
- on conflict (account_id) do update set instance_url = excluded.instance_url, paperclip_user_id = excluded.paperclip_user_id, sealed_credential = excluded.sealed_credential, connected_at = excluded.connected_at, last_used_at = excluded.last_used_at, instance_label = excluded.instance_label`, [l.accountId, l.instanceUrl, l.paperclipUserId, l.sealedCredential, l.createdAt, l.connectedAt, l.lastUsedAt, l.instanceLabel ?? null]);
287
+ await this.q(`insert into bridge.account_links (account_id, instance_url, paperclip_user_id, sealed_credential, created_at, connected_at, last_used_at, instance_label, credential_expires_at) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
288
+ on conflict (account_id) do update set instance_url = excluded.instance_url, paperclip_user_id = excluded.paperclip_user_id, sealed_credential = excluded.sealed_credential, credential_expires_at = excluded.credential_expires_at, connected_at = excluded.connected_at, last_used_at = excluded.last_used_at, instance_label = excluded.instance_label`, [l.accountId, l.instanceUrl, l.paperclipUserId, l.sealedCredential, l.createdAt, l.connectedAt, l.lastUsedAt, l.instanceLabel ?? null, l.credentialExpiresAt ?? null]);
288
289
  return true;
289
290
  }
290
291
  catch (e) {
@@ -312,6 +313,18 @@ export class PgStore {
312
313
  async listIdleLinks(cutoff) {
313
314
  return (await this.q("select * from bridge.account_links where sealed_credential is not null and last_used_at < $1", [cutoff])).rows.map(PgStore.toLink);
314
315
  }
316
+ async listExpiringLinks(before) {
317
+ return (await this.q("select * from bridge.account_links where sealed_credential is not null and credential_expires_at < $1", [before])).rows.map(PgStore.toLink);
318
+ }
319
+ async listExpiringGrants(before) {
320
+ return (await this.q("select * from bridge.grants where not revoked and sealed_credential is not null and credential_expires_at < $1", [before])).rows.map(toGrant);
321
+ }
322
+ async swapCredential(h, expect, next, expiresAt) {
323
+ const sql = h.kind === "link"
324
+ ? "update bridge.account_links set sealed_credential = $3, credential_expires_at = $4 where account_id = $1 and sealed_credential = $2"
325
+ : "update bridge.grants set sealed_credential = $3, credential_expires_at = $4 where id = $1 and sealed_credential = $2 and not revoked";
326
+ return ((await this.q(sql, [h.id, expect, next, expiresAt])).rowCount ?? 0) === 1;
327
+ }
315
328
  async revokeAccountGrants(accountId) {
316
329
  const c = await this.pool.connect();
317
330
  try {
@@ -7,11 +7,11 @@ import { hostAllowed } from "../config.js";
7
7
  import { UnsafeUrlError, createSafeFetch, parseInstanceUrl } from "../net/safe-fetch.js";
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
- import { LoginError, PaperclipLogin } from "./paperclip-login.js";
10
+ import { LoginError, PAPERCLIP_KEY_TTL_MS, PaperclipLogin } from "./paperclip-login.js";
11
11
  import { parseAccountPolicy, parseSessionPolicy, readAccountPolicy, readSessionPolicy, surfaceOfTool } from "../access/policy.js";
12
12
  import { tools as ALL_TOOLS } from "../tools.js";
13
13
  import { VERSION } from "../version.js";
14
- import { MemoryStore } from "./store.js";
14
+ import { MemoryStore, credentialExpiry } from "./store.js";
15
15
  import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
16
16
  const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
17
17
  const PENDING_TTL_MS = 10 * 60_000;
@@ -28,6 +28,12 @@ const PLUGIN_KEY = "papercliped.remote-control";
28
28
  const PLUGIN_INSTALL_MS = 120_000;
29
29
  const MAX_FORM_BYTES = 16 * 1024;
30
30
  const DAY_MS = 24 * 3600 * 1000;
31
+ /** Renew a stored Paperclip key this long before Paperclip expires it. */
32
+ const ROTATE_BEFORE_MS = 7 * DAY_MS;
33
+ /** After a rotation attempt, wait this long before trying that key again (shared across processes). */
34
+ const ROTATE_RETRY_MS = 10 * 60_000;
35
+ /** Revoke the replaced key only after this, so calls already under way with it (here or in another process) finish. */
36
+ const ROTATE_GRACE_MS = 60_000;
31
37
  class OAuthError extends Error {
32
38
  code;
33
39
  status;
@@ -75,6 +81,10 @@ export class OAuthProvider {
75
81
  background = new Set();
76
82
  multi;
77
83
  accounts;
84
+ /** Key rotations under way in this process, by holder: callers wait for one instead of starting another. */
85
+ rotations = new Map();
86
+ /** Holder → when this process may try a failed rotation again. */
87
+ rotateRetryAt = new Map();
78
88
  constructor(deps) {
79
89
  this.deps = deps;
80
90
  this.now = deps.now ?? Date.now;
@@ -158,7 +168,8 @@ export class OAuthProvider {
158
168
  /** The Paperclip client for a grant. In account mode the credential and instance come from the account's link. */
159
169
  async resolveClient(grant) {
160
170
  if (!this.accounts || !grant.accountId) {
161
- const apiKey = grant.sealedCredential ? this.keyring.unseal(grant.sealedCredential) : null;
171
+ const sealed = grant.sealedCredential ? await this.currentKey(this.heldOnGrant(grant, grant.sealedCredential)) : null;
172
+ const apiKey = sealed ? this.keyring.unseal(sealed) : null;
162
173
  if (!this.multi)
163
174
  return { client: new PaperclipClient({ ...this.deps.config, apiKey }) };
164
175
  if (!grant.instanceUrl)
@@ -171,7 +182,7 @@ export class OAuthProvider {
171
182
  throw new ReconnectRequired("This account's Paperclip connection has expired; reconnect to continue.");
172
183
  const origin = this.checkedInstance(link.instanceUrl).origin; // re-validated on every use (policy can tighten)
173
184
  await this.store.touchLink(grant.accountId);
174
- const apiKey = this.keyring.unseal(link.sealedCredential);
185
+ const apiKey = this.keyring.unseal(await this.currentKey(this.heldOnLink(link, link.sealedCredential)));
175
186
  return { client: new PaperclipClient({ ...this.deps.config, apiUrl: `${origin}/api`, apiKey, companyId: null }, this.safeFetch), instanceHost: link.instanceLabel ?? new URL(origin).host };
176
187
  }
177
188
  /** Convenience for callers that only need the client. */
@@ -201,22 +212,23 @@ export class OAuthProvider {
201
212
  /* instance no longer allowed / key gone: nothing more we can do */
202
213
  }
203
214
  }
204
- /** Revoke idle grants and idle account connections, delete their stored credentials, prune telemetry. Run periodically. */
215
+ /** Revoke idle grants and idle account connections, delete their stored credentials, renew Paperclip keys close to expiry, prune telemetry. Run periodically. */
205
216
  async sweep() {
206
217
  const days = this.deps.oauth.idleRevokeDays;
207
218
  await this.store.prune();
208
- if (!days)
209
- return 0;
210
- const cutoff = this.now() - days * DAY_MS;
211
219
  let n = 0;
212
- const idle = await this.store.listIdleGrants(cutoff);
213
- for (const g of idle)
214
- await this.revokeGrant(g.id);
215
- n += idle.length;
216
- for (const l of await this.store.listIdleLinks(cutoff)) {
217
- await this.dropConnection(l, "idle");
218
- n += 1;
220
+ if (days) {
221
+ const cutoff = this.now() - days * DAY_MS;
222
+ const idle = await this.store.listIdleGrants(cutoff);
223
+ for (const g of idle)
224
+ await this.revokeGrant(g.id);
225
+ n += idle.length;
226
+ for (const l of await this.store.listIdleLinks(cutoff)) {
227
+ await this.dropConnection(l, "idle");
228
+ n += 1;
229
+ }
219
230
  }
231
+ await this.rotateExpiring();
220
232
  return n;
221
233
  }
222
234
  /** Forget an account's stored Paperclip credential (the username stays) and tell its Paperclip to revoke the key. */
@@ -237,6 +249,125 @@ export class OAuthProvider {
237
249
  /* best effort */
238
250
  }
239
251
  }
252
+ // ───────────── Paperclip key rotation ─────────────
253
+ // Paperclip expires the board keys it issues (30 days by default). Each stored key is renewed about a week before
254
+ // that: the old key asks Paperclip for a new one, the new one is swapped in only if the old one is still stored
255
+ // (compare-and-swap), and only then is the old one revoked. Any failure leaves the working key in place.
256
+ heldOnLink(l, sealed) {
257
+ return { kind: "link", id: l.accountId, instanceUrl: l.instanceUrl, sealed, expiresAt: credentialExpiry(l.credentialExpiresAt, l.connectedAt) };
258
+ }
259
+ heldOnGrant(g, sealed) {
260
+ return { kind: "grant", id: g.id, instanceUrl: g.instanceUrl, sealed, expiresAt: credentialExpiry(g.credentialExpiresAt, g.createdAt) };
261
+ }
262
+ /** The sealed key to call Paperclip with: the stored one, or its replacement when it was due and got renewed. */
263
+ async currentKey(k) {
264
+ if (k.expiresAt === null || k.expiresAt - this.now() > ROTATE_BEFORE_MS)
265
+ return k.sealed;
266
+ const slot = `${k.kind}:${k.id}`;
267
+ let run = this.rotations.get(slot);
268
+ if (!run) {
269
+ if ((this.rotateRetryAt.get(slot) ?? 0) > this.now())
270
+ return k.sealed;
271
+ run = this.rotate(k, slot).finally(() => this.rotations.delete(slot));
272
+ this.rotations.set(slot, run);
273
+ }
274
+ return (await run) ?? k.sealed;
275
+ }
276
+ /** Renew every stored key that expires within the rotation window, so keys of people who rarely call get renewed too. */
277
+ async rotateExpiring() {
278
+ const before = this.now() + ROTATE_BEFORE_MS;
279
+ for (const l of await this.store.listExpiringLinks(before))
280
+ if (l.sealedCredential)
281
+ await this.currentKey(this.heldOnLink(l, l.sealedCredential));
282
+ for (const g of await this.store.listExpiringGrants(before))
283
+ if (g.sealedCredential)
284
+ await this.currentKey(this.heldOnGrant(g, g.sealedCredential));
285
+ }
286
+ /** One rotation. Resolves to the new sealed key, or null to keep using the stored one. Never throws. */
287
+ async rotate(k, slot) {
288
+ let outcome = null;
289
+ let who = null;
290
+ try {
291
+ // One rotation per key across every process: whoever takes the claim does it; the others keep the old key, which still works.
292
+ if (!(await this.store.hit(`rotate:${slot}`, 1, ROTATE_RETRY_MS))) {
293
+ this.rotateRetryAt.set(slot, this.now() + ROTATE_RETRY_MS);
294
+ return null;
295
+ }
296
+ const token = this.keyring.unseal(k.sealed);
297
+ if (k.kind === "grant" && token === this.deps.config.apiKey) {
298
+ // The operator's own PAPERCLIP_API_KEY (bridge admin token sign-in): not ours to replace.
299
+ await this.store.swapCredential(k, k.sealed, k.sealed, null);
300
+ return null;
301
+ }
302
+ let name = "Papercliped";
303
+ if (k.kind === "link") {
304
+ const a = await this.store.getAccount(k.id);
305
+ if (a)
306
+ name = `Papercliped (${a.username})`;
307
+ who = a ? { accountId: a.id, username: displayName(a) } : { accountId: k.id, username: "" };
308
+ }
309
+ else {
310
+ const g = await this.store.getGrant(k.id);
311
+ if (g)
312
+ name = `Papercliped (${g.username ?? g.clientName})`;
313
+ who = g ? { accountId: g.accountId ?? null, username: g.username ?? null } : null;
314
+ }
315
+ const login = this.loginFor(k.instanceUrl);
316
+ outcome = "failed";
317
+ const info = await login.keyInfo(token);
318
+ if (info.state === "unsupported" || info.expiresAt === null) {
319
+ // Older Paperclip, or a key that never expires: nothing to renew, now or later.
320
+ outcome = null;
321
+ await this.store.swapCredential(k, k.sealed, k.sealed, null);
322
+ return null;
323
+ }
324
+ if (info.expiresAt !== undefined && info.expiresAt - this.now() > ROTATE_BEFORE_MS) {
325
+ outcome = null; // the stored expiry was an estimate; Paperclip's own is later
326
+ await this.store.swapCredential(k, k.sealed, k.sealed, info.expiresAt);
327
+ return null;
328
+ }
329
+ const made = await login.createKey(token, name);
330
+ if (made.state === "unsupported") {
331
+ outcome = null;
332
+ await this.store.swapCredential(k, k.sealed, k.sealed, null);
333
+ return null;
334
+ }
335
+ const next = this.keyring.seal(made.token);
336
+ if (!(await this.store.swapCredential(k, k.sealed, next, made.expiresAt === undefined ? this.now() + PAPERCLIP_KEY_TTL_MS : made.expiresAt))) {
337
+ // Reconnected, rotated elsewhere or disconnected meanwhile: what is stored now wins; drop the key we just made.
338
+ outcome = null;
339
+ await login.revoke(made.token);
340
+ return null;
341
+ }
342
+ outcome = "rotated";
343
+ this.revokeReplaced(login, token);
344
+ return next;
345
+ }
346
+ catch (e) {
347
+ outcome = "failed";
348
+ this.rotateRetryAt.set(slot, this.now() + ROTATE_RETRY_MS);
349
+ // Paperclip no longer accepts this key (expired or revoked): it can never be renewed, so stop trying. Using it
350
+ // still gets the reconnect hint, and reconnecting stores a fresh key with a fresh expiry.
351
+ if (e instanceof LoginError && e.status === 401)
352
+ await this.store.swapCredential(k, k.sealed, k.sealed, null).catch(() => false);
353
+ return null;
354
+ }
355
+ finally {
356
+ if (outcome)
357
+ await this.event("key", who, outcome);
358
+ }
359
+ }
360
+ /** Revoke a key that rotation replaced, after a grace period for calls still using it. Best effort: it expires on its own anyway. */
361
+ revokeReplaced(login, token) {
362
+ const grace = this.deps.rotationGraceMs ?? ROTATE_GRACE_MS;
363
+ if (grace <= 0) {
364
+ const run = login.revoke(token);
365
+ const tracked = run.finally(() => this.background.delete(tracked));
366
+ this.background.add(tracked);
367
+ return;
368
+ }
369
+ setTimeout(() => void login.revoke(token), grace).unref();
370
+ }
240
371
  // ───────────── account management (the Paperclip plugin's connection manager) ─────────────
241
372
  // Everything here acts on ONE account and is reached only through ManageRoutes, with a plugin token or the secret key.
242
373
  /** Username + secret key, throttled exactly like the sign-in page (shared counters, so there is no cheaper way to guess). */
@@ -918,7 +1049,7 @@ export class OAuthProvider {
918
1049
  const t = this.now();
919
1050
  const acct = await this.store.getAccount(accountId);
920
1051
  const instanceLabel = acct ? this.instanceLabelFor(acct, instanceUrl, old) : new URL(instanceUrl).host;
921
- const ok = await this.store.putLink({ accountId, instanceUrl, paperclipUserId, sealedCredential: this.keyring.seal(credential), createdAt: old?.createdAt ?? t, connectedAt: t, lastUsedAt: t, instanceLabel });
1052
+ const ok = await this.store.putLink({ accountId, instanceUrl, paperclipUserId, sealedCredential: this.keyring.seal(credential), credentialExpiresAt: t + PAPERCLIP_KEY_TTL_MS, createdAt: old?.createdAt ?? t, connectedAt: t, lastUsedAt: t, instanceLabel });
922
1053
  if (ok && old?.sealedCredential) {
923
1054
  let same = false;
924
1055
  try {
@@ -1163,7 +1294,7 @@ export class OAuthProvider {
1163
1294
  }
1164
1295
  if (!created)
1165
1296
  return this.renderStage(res, rid, p, "Could not reserve an anonymous name right now. Please try again.", { value: typed });
1166
- const linked = await this.store.putLink({ accountId: account.id, instanceUrl: p.instanceUrl, paperclipUserId: p.paperclipUserId, sealedCredential: this.keyring.seal(ch.boardApiToken), createdAt: t, connectedAt: t, lastUsedAt: t, instanceLabel: this.instanceLabelFor(account, p.instanceUrl) });
1297
+ const linked = await this.store.putLink({ accountId: account.id, instanceUrl: p.instanceUrl, paperclipUserId: p.paperclipUserId, sealedCredential: this.keyring.seal(ch.boardApiToken), credentialExpiresAt: t + PAPERCLIP_KEY_TTL_MS, createdAt: t, connectedAt: t, lastUsedAt: t, instanceLabel: this.instanceLabelFor(account, p.instanceUrl) });
1167
1298
  if (!linked) {
1168
1299
  await this.store.deleteAccount(account.id); // lost a race for this Paperclip identity
1169
1300
  p.stage = "choose";
@@ -1291,6 +1422,8 @@ export class OAuthProvider {
1291
1422
  resource: this.resource,
1292
1423
  instanceUrl: entry.instanceUrl,
1293
1424
  sealedCredential: entry.sealedCredential,
1425
+ // A key from an approved sign-in expires with Paperclip's default; the operator's own PAPERCLIP_API_KEY is never rotated.
1426
+ credentialExpiresAt: entry.sealedCredential && this.keyring.unseal(entry.sealedCredential) !== this.deps.config.apiKey ? t + PAPERCLIP_KEY_TTL_MS : null,
1294
1427
  accountId: entry.accountId ?? null,
1295
1428
  username: entry.username ?? null,
1296
1429
  createdAt: t,
@@ -1,7 +1,12 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { AliasTakenError } from "../accounts/types.js";
4
+ import { PAPERCLIP_KEY_TTL_MS } from "./paperclip-login.js";
4
5
  import { HIST_EDGES, isFault } from "../telemetry/types.js";
6
+ /** A stored credential's expiry; old rows without one are assumed to carry Paperclip's default TTL from `issuedAt`. */
7
+ export function credentialExpiry(expiresAt, issuedAt) {
8
+ return expiresAt === undefined ? issuedAt + PAPERCLIP_KEY_TTL_MS : expiresAt;
9
+ }
5
10
  const MAX_USER_EVENTS = 20_000;
6
11
  const MAX_CLIENTS = 1000;
7
12
  const CLIENT_IDLE_MS = 7 * 24 * 3600 * 1000;
@@ -134,6 +139,21 @@ export class MemoryStore {
134
139
  async listIdleGrants(cutoff) {
135
140
  return Object.values(this.d.grants).filter((g) => !g.revoked && g.lastUsedAt < cutoff);
136
141
  }
142
+ async listExpiringGrants(before) {
143
+ return Object.values(this.d.grants).filter((g) => {
144
+ const exp = g.sealedCredential && !g.revoked ? credentialExpiry(g.credentialExpiresAt, g.createdAt) : null;
145
+ return exp !== null && exp < before;
146
+ });
147
+ }
148
+ async swapCredential(h, expect, next, expiresAt) {
149
+ const row = h.kind === "link" ? this.lnks[h.id] : this.d.grants[h.id];
150
+ if (!row || row.sealedCredential !== expect || (h.kind === "grant" && row.revoked))
151
+ return false;
152
+ row.sealedCredential = next;
153
+ row.credentialExpiresAt = expiresAt;
154
+ this.save();
155
+ return true;
156
+ }
137
157
  async putAccess(hash, r) {
138
158
  this.d.access[hash] = r;
139
159
  this.save();
@@ -358,6 +378,12 @@ export class MemoryStore {
358
378
  async listIdleLinks(cutoff) {
359
379
  return Object.values(this.lnks).filter((l) => l.sealedCredential && l.lastUsedAt < cutoff);
360
380
  }
381
+ async listExpiringLinks(before) {
382
+ return Object.values(this.lnks).filter((l) => {
383
+ const exp = l.sealedCredential ? credentialExpiry(l.credentialExpiresAt, l.connectedAt) : null;
384
+ return exp !== null && exp < before;
385
+ });
386
+ }
361
387
  async revokeAccountGrants(accountId) {
362
388
  let n = 0;
363
389
  for (const g of Object.values(this.d.grants)) {
@@ -7,7 +7,7 @@ Everything for the public launch: what must be true first, how to get the first
7
7
  | | Check | How |
8
8
  | --- | --- | --- |
9
9
  | ☐ | The latest release is live | `https://mcp.papercliped.co/api/public/v1/info` shows the version in `package.json`; papercliped.co/prompts and docs.papercliped.co/hosting load. If not, Render → **Manual Deploy → Deploy latest commit**. |
10
- | ☐ | Connections survive past 30 days | Paperclip expires approved keys after 30 days; ship key rotation before inviting people, or every tester drops off a month later. |
10
+ | ☐ | Connections survive past 30 days | Paperclip expires approved keys after 30 days; the bridge renews each key a week before (v2.5.1). Run the `008_key_rotation.sql` migration (`npm run migrate`) on the production database when deploying it. |
11
11
  | ☐ | A full run-through on a fresh Paperclip | Follow [Host your Paperclip](https://docs.papercliped.co/hosting) start to finish on one guide (Railway or a VPS), connect Claude **and** ChatGPT, run five prompts from the gallery, flip the access switch in the control room. Fix anything that trips you up. |
12
12
  | ☐ | Discord and X exist | Create them with the icon pack (below), then set `VITE_DISCORD_URL` / `VITE_X_URL` on Render and rebuild. Set up Discord per [COMMUNITY.md](COMMUNITY.md), plus a `#testers` channel. |
13
13
  | ☐ | GitHub is launch-ready | Turn on Discussions, set the repository social preview to `social-light-1200x630.png`, pin a "Start here" discussion, add topics: `mcp`, `paperclip`, `ai-agents`, `claude`, `chatgpt`, `codex`, `mcp-server`. |
@@ -285,6 +285,23 @@ alter table bridge.grants add column if not exists policy jsonb;
285
285
  -- Audit rows now also record calls the policy refused (error_class = policy_blocked).
286
286
  comment on column bridge.audit_events.error_class is 'invalid_input | insufficient_scope | read_only | policy_blocked | upstream_4xx | upstream_5xx | upstream_unreachable | rate_limited | unauthorized | internal';
287
287
 
288
+ -- ───── 008_key_rotation.sql ─────
289
+ -- Paperclip key rotation: Paperclip expires the board keys it issues (30 days by default), so the bridge renews
290
+ -- each stored key about a week before it lapses. credential_expires_at is when Paperclip expires the stored key
291
+ -- (ms epoch); null = it never expires (older Paperclip, or a key Paperclip did not issue us), so it is never rotated.
292
+ alter table bridge.account_links add column if not exists credential_expires_at bigint;
293
+ alter table bridge.grants add column if not exists credential_expires_at bigint;
294
+
295
+ -- Keys stored before this migration came from an approved sign-in: assume Paperclip's default 30 days from then.
296
+ -- The first rotation check reads the real expiry from Paperclip and corrects it (or clears it if the key never expires).
297
+ update bridge.account_links set credential_expires_at = connected_at + 2592000000
298
+ where sealed_credential is not null and credential_expires_at is null;
299
+ update bridge.grants set credential_expires_at = created_at + 2592000000
300
+ where sealed_credential is not null and not revoked and credential_expires_at is null;
301
+
302
+ create index if not exists account_links_expiry_idx on bridge.account_links (credential_expires_at) where sealed_credential is not null;
303
+ create index if not exists grants_expiry_idx on bridge.grants (credential_expires_at) where sealed_credential is not null and not revoked;
304
+
288
305
  -- ───── migration bookkeeping ─────
289
306
  create table if not exists bridge.schema_migrations (version text primary key, applied_at timestamptz not null default now());
290
307
  alter table bridge.schema_migrations enable row level security;
@@ -294,6 +311,6 @@ do $$ declare r text; begin
294
311
  if exists (select 1 from pg_roles where rolname = r) then execute format('revoke all on bridge.schema_migrations from %I', r); end if;
295
312
  end loop;
296
313
  end $$;
297
- insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql') on conflict (version) do nothing;
314
+ insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql'), ('008_key_rotation.sql') on conflict (version) do nothing;
298
315
 
299
316
  commit;
@@ -0,0 +1,15 @@
1
+ -- Paperclip key rotation: Paperclip expires the board keys it issues (30 days by default), so the bridge renews
2
+ -- each stored key about a week before it lapses. credential_expires_at is when Paperclip expires the stored key
3
+ -- (ms epoch); null = it never expires (older Paperclip, or a key Paperclip did not issue us), so it is never rotated.
4
+ alter table bridge.account_links add column if not exists credential_expires_at bigint;
5
+ alter table bridge.grants add column if not exists credential_expires_at bigint;
6
+
7
+ -- Keys stored before this migration came from an approved sign-in: assume Paperclip's default 30 days from then.
8
+ -- The first rotation check reads the real expiry from Paperclip and corrects it (or clears it if the key never expires).
9
+ update bridge.account_links set credential_expires_at = connected_at + 2592000000
10
+ where sealed_credential is not null and credential_expires_at is null;
11
+ update bridge.grants set credential_expires_at = created_at + 2592000000
12
+ where sealed_credential is not null and not revoked and credential_expires_at is null;
13
+
14
+ create index if not exists account_links_expiry_idx on bridge.account_links (credential_expires_at) where sealed_credential is not null;
15
+ create index if not exists grants_expiry_idx on bridge.grants (credential_expires_at) where sealed_credential is not null and not revoked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.5.0",
3
+ "version": "2.5.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",
@@ -14,6 +14,7 @@ What Papercliped does to protect you, and what it cannot.
14
14
  - **OAuth 2.1 with PKCE**, short-lived access tokens, rotating refresh tokens with reuse detection.
15
15
  - **Rate limits** on sign-in, secret-key attempts and tool calls.
16
16
  - **A separate operator dashboard** (an access-controlled service, not part of the open-source bridge) reads the database through a read-only role that can see only safe views: never your key, secret hash or an anonymous account's real name.
17
+ - **Keys are renewed, not made permanent.** Paperclip expires the keys it issues (30 days on newer versions). About a week before that, Papercliped asks Paperclip for a fresh key with the same expiry, stores it encrypted, and revokes the old one. It never asks for a key that doesn't expire. In Paperclip the key is named "Papercliped (your username)". If renewal fails, the current key keeps working and Papercliped tries again later.
17
18
  - Revocation is immediate on disconnect, and idle keys are removed after 30 days.
18
19
 
19
20
  ## What it cannot do
@@ -4,7 +4,7 @@
4
4
 
5
5
  **"This hostname is not allowed for this Paperclip instance."** Paperclip doesn't recognise the address. Set `PAPERCLIP_DEPLOYMENT_EXPOSURE=public` and `PAPERCLIP_PUBLIC_URL` to exactly the address you use, then restart Paperclip. See [What every option needs](/docs/hosting#what-every-option-needs).
6
6
 
7
- **It stopped working after about a month.** Newer Paperclip versions make keys approved through the sign-in prompt expire after 30 days. When the AI says the Paperclip credential was rejected, disconnect and connect again from your AI app, then approve in Paperclip.
7
+ **"The Paperclip credential behind this connection was rejected."** Newer Paperclip versions expire keys after 30 days. Papercliped renews yours automatically about a week before that, so this should not happen on its own. It does if the key was revoked in Paperclip, the renewal kept failing until the key expired (for example, Paperclip was unreachable that whole week), or your Paperclip account lost access. Disconnect and connect again from your AI app, then approve in Paperclip.
8
8
 
9
9
  **Agents stop mid-task now and then.** Something restarted Paperclip: an update, a redeploy, or a host that sleeps. Runs in progress end on every restart. Use an always-on host and update when agents are idle.
10
10