papercliped 1.1.0-beta.2 → 2.1.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 (83) 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 +64 -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 +28 -18
  16. package/dist/oauth/pg-store.js +0 -13
  17. package/dist/oauth/provider.js +27 -63
  18. package/dist/oauth/scopes.js +19 -16
  19. package/dist/openapi.js +19 -2
  20. package/dist/public-api/health.js +46 -0
  21. package/dist/public-api/openapi.js +51 -0
  22. package/dist/public-api/routes.js +251 -0
  23. package/dist/public-api/types.js +14 -0
  24. package/dist/server.js +4 -0
  25. package/dist/site/landing.js +3 -3
  26. package/dist/site/markdown.js +2 -1
  27. package/dist/site/site.js +16 -4
  28. package/dist/telemetry/recorder.js +1 -1
  29. package/dist/tools.js +48 -2
  30. package/dist/web/csp.js +16 -0
  31. package/dist/web/hosts.js +23 -0
  32. package/dist/web/routes.js +132 -0
  33. package/docs/ARCHITECTURE.md +74 -73
  34. package/docs/COMMUNITY.md +86 -0
  35. package/docs/DEPLOY-RENDER.md +3 -3
  36. package/docs/DOMAINS.md +74 -0
  37. package/docs/LAUNCH.md +50 -34
  38. package/docs/RELEASING.md +37 -0
  39. package/docs/SECURITY.md +82 -65
  40. package/docs/SITEMAP.md +92 -0
  41. package/docs/V3-SUBDOMAINS.md +106 -0
  42. package/docs/chatgpt.md +20 -20
  43. package/docs/least-privilege.sql +23 -23
  44. package/docs/legal/PRIVACY.md +37 -37
  45. package/docs/legal/TERMS.md +11 -11
  46. package/docs/oauth.md +61 -62
  47. package/docs/supabase-schema.sql +8 -1
  48. package/migrations/001_init.sql +84 -84
  49. package/migrations/002_audit.sql +41 -41
  50. package/migrations/003_accounts.sql +66 -66
  51. package/migrations/004_privacy_panel.sql +57 -57
  52. package/migrations/006_two_levels.sql +5 -0
  53. package/package.json +10 -14
  54. package/site/docs/chatgpt.md +1 -1
  55. package/site/docs/cli.md +27 -0
  56. package/site/docs/connect-your-paperclip.md +120 -0
  57. package/site/docs/environment.md +64 -0
  58. package/site/docs/faq.md +31 -0
  59. package/site/docs/getting-started.md +11 -5
  60. package/site/docs/limits-and-errors.md +32 -0
  61. package/site/docs/manage-api.md +33 -0
  62. package/site/docs/manage.md +12 -21
  63. package/site/docs/other-ai-apps.md +28 -0
  64. package/site/docs/paperclip-plugin.md +32 -0
  65. package/site/docs/permissions.md +5 -5
  66. package/site/docs/public-api.md +62 -0
  67. package/site/docs/security.md +2 -2
  68. package/site/docs/self-hosting.md +43 -0
  69. package/site/docs/signup.md +3 -3
  70. package/site/docs/tools.md +47 -0
  71. package/site/docs/troubleshooting.md +2 -2
  72. package/site/docs/what-is-papercliped.md +37 -0
  73. package/site/privacy.md +5 -3
  74. package/site/terms.md +1 -1
  75. package/skills/paperclip/SKILL.md +23 -23
  76. package/dist/demo.js +0 -144
  77. package/dist/manage/page.js +0 -170
  78. package/dist/panel/config.js +0 -61
  79. package/dist/panel/main.js +0 -90
  80. package/dist/panel/page.js +0 -553
  81. package/dist/panel/routes.js +0 -284
  82. package/dist/panel/server.js +0 -37
  83. package/docs/panel-role.sql +0 -26
@@ -1,65 +1,40 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
1
  import { displayName } from "../accounts/alias.js";
3
- import { randomToken, sha256Hex } from "../oauth/crypto.js";
4
- import { managePage } from "./page.js";
5
- const COOKIE = "pcp_manage";
6
2
  const HEADERS = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" };
3
+ /** A DNS name or IPv4 literal, optionally with a port; undefined when it is anything else. */
4
+ function cleanHost(v) {
5
+ if (typeof v !== "string")
6
+ return undefined;
7
+ const h = v.trim().toLowerCase();
8
+ if (!h)
9
+ return null;
10
+ if (h.length > 255 || !/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/.test(h))
11
+ return undefined;
12
+ try {
13
+ return new URL(`https://${h}`).host;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ const cleanUid = (v) => (v == null ? null : typeof v === "string" && v.length <= 100 ? v : undefined);
7
20
  /**
8
- * The connection manager for beta accounts: sign in with username + secret key, then see every connected app, change what each
9
- * may do, disconnect, switch anonymity, rotate the secret key, or disconnect/delete everything. Every action is limited to the
10
- * signed-in account; sensitive ones ask for the secret key again.
21
+ * The manage API behind the Papercliped plugin for Paperclip (there is no web page for it any more: managing happens inside
22
+ * Paperclip). The plugin signs in once with username + secret key and gets a plugin token; with it, it can list and change
23
+ * connected apps, switch anonymity, and list or remove plugin links. Sensitive actions (new secret key, disconnect Paperclip,
24
+ * delete account) need the secret key again in the request. Every action is limited to the token's own account.
11
25
  *
12
- * CSRF: the session cookie is SameSite=Strict, and every state-changing call must be JSON with a custom header, which a
13
- * cross-site page cannot send without a CORS preflight (which we never approve).
26
+ * Only bearer tokens are accepted, and a cross-site page cannot attach one without a CORS preflight (which we never approve),
27
+ * so there is no cookie and no CSRF surface.
14
28
  */
15
29
  export class ManageRoutes {
16
30
  p;
17
- o;
18
- key;
19
- now;
20
- ttl;
21
- constructor(p, o) {
31
+ constructor(p, _o = {}) {
22
32
  this.p = p;
23
- this.o = o;
24
- this.key = p.manageSigningKey();
25
- this.now = o.now ?? Date.now;
26
- this.ttl = (o.sessionHours ?? 8) * 3600;
27
- }
28
- fp(a) {
29
- return sha256Hex(a.secretHash).slice(0, 12);
30
- }
31
- mac(accountId, exp, fp) {
32
- return createHmac("sha256", this.key).update(`${accountId}|${exp}|${fp}`).digest("base64url");
33
- }
34
- cookie(a) {
35
- if (!a)
36
- return `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${this.o.secureCookie ? "; Secure" : ""}`;
37
- const exp = Math.floor(this.now() / 1000) + this.ttl;
38
- const fp = this.fp(a);
39
- return `${COOKIE}=${a.id}~${exp}~${fp}~${this.mac(a.id, exp, fp)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${this.ttl}${this.o.secureCookie ? "; Secure" : ""}`;
40
- }
41
- async session(req) {
42
- const m = new RegExp(`(?:^|; )${COOKIE}=([^;]+)`).exec(req.headers.cookie ?? "");
43
- if (!m)
44
- return null;
45
- const [id, exp, fp, mac] = m[1].split("~");
46
- if (!id || !exp || !fp || !mac || Number(exp) * 1000 < this.now())
47
- return null;
48
- const want = Buffer.from(this.mac(id, Number(exp), fp));
49
- const got = Buffer.from(mac);
50
- if (want.length !== got.length || !timingSafeEqual(want, got))
51
- return null;
52
- const a = await this.p.manageAccount(id);
53
- // Changing the secret key changes the fingerprint, which ends every session signed with the old one.
54
- return a && !a.disabled && this.fp(a) === fp ? a : null;
55
- }
56
- send(res, status, body, type, extra = {}) {
57
- res.writeHead(status, { "Content-Type": type, ...HEADERS, ...extra });
58
- res.end(body);
59
- return true;
60
33
  }
61
34
  json(res, status, body, extra = {}) {
62
- return this.send(res, status, JSON.stringify(body), "application/json", extra);
35
+ res.writeHead(status, { "Content-Type": "application/json", ...HEADERS, ...extra });
36
+ res.end(JSON.stringify(body));
37
+ return true;
63
38
  }
64
39
  async body(req) {
65
40
  if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? "")))
@@ -81,50 +56,42 @@ export class ManageRoutes {
81
56
  async handle(req, res, url) {
82
57
  const path = url.pathname.replace(/\/+$/, "") || "/";
83
58
  const m = req.method ?? "GET";
84
- if (m === "GET" && path === "/manage") {
85
- const nonce = randomToken(12);
86
- return this.send(res, 200, managePage(nonce), "text/html; charset=utf-8", {
87
- "Content-Security-Policy": `default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
88
- });
59
+ // The old browser page: managing now happens in Paperclip, with the plugin.
60
+ if (path === "/manage") {
61
+ res.writeHead(302, { Location: "/docs/paperclip-plugin", ...HEADERS });
62
+ res.end();
63
+ return true;
89
64
  }
90
65
  if (!path.startsWith("/api/manage"))
91
66
  return false;
92
- if (m !== "GET") {
93
- // CSRF guard (see class comment).
94
- if (req.headers["x-papercliped"] !== "1")
95
- return this.json(res, 400, { error: "Missing request header" });
96
- }
97
67
  const route = path.slice("/api/manage".length) || "/";
98
- if (m === "POST" && route === "/login") {
68
+ // The plugin proves the secret key once and gets its token. Throttled exactly like the sign-in page.
69
+ if (m === "POST" && route === "/plugin-link/sign-in") {
99
70
  const b = await this.body(req);
100
71
  if (!b)
101
72
  return this.json(res, 400, { error: "Send JSON" });
73
+ const host = b.instanceHost == null ? null : cleanHost(b.instanceHost);
74
+ const uid = cleanUid(b.paperclipUserId);
75
+ if (host === undefined || uid === undefined)
76
+ return this.json(res, 400, { error: "Invalid instanceHost or paperclipUserId" });
102
77
  const r = await this.p.manageLogin(req, String(b.username ?? ""), String(b.secret ?? ""));
103
78
  if (!r.ok)
104
79
  return r.reason === "throttled" ? this.json(res, 429, { error: "Too many attempts. Wait a few minutes." }) : this.json(res, 401, { error: "That username and secret key don't match." });
105
- return this.json(res, 200, { ok: true }, { "Set-Cookie": this.cookie(r.account) });
80
+ const { token } = await this.p.manageLinkPlugin(r.account, { instanceHost: host, paperclipUserId: uid });
81
+ return this.json(res, 200, { token });
106
82
  }
107
- const a = await this.session(req);
108
- if (!a)
109
- return this.json(res, 401, { error: "Sign in first" });
83
+ const bearer = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization ?? ""))?.[1] ?? null;
84
+ const pr = bearer ? await this.p.managePluginPrincipal(bearer) : null;
85
+ if (!pr)
86
+ return this.json(res, 401, { error: "Link the Papercliped plugin again" });
87
+ const a = pr.account;
88
+ const linkId = pr.linkId;
110
89
  if (!(await this.p.store.hit(`manage:${a.id}`, 120, 60_000)))
111
90
  return this.json(res, 429, { error: "Slow down" });
112
- if (m === "POST" && route === "/logout")
113
- return this.json(res, 200, { ok: true }, { "Set-Cookie": this.cookie(null) });
114
91
  if (m === "GET" && route === "/me") {
115
92
  const link = await this.p.manageInstanceLabel(a.id);
116
- return this.json(res, 200, { name: displayName(a), anonymous: !!a.anonymous, alias: a.alias ?? null, beta: !!a.beta, paperclip: link.label, connected: link.connected, createdAt: a.createdAt });
117
- }
118
- if (m === "POST" && route === "/beta") {
119
- const b = await this.body(req);
120
- if (!b || typeof b.beta !== "boolean")
121
- return this.json(res, 400, { error: "beta must be true or false" });
122
- await this.p.manageSetBeta(a, b.beta);
123
- return this.json(res, 200, { beta: b.beta });
93
+ return this.json(res, 200, { name: displayName(a), anonymous: !!a.anonymous, alias: a.alias ?? null, paperclip: link.label, connected: link.connected, createdAt: a.createdAt });
124
94
  }
125
- // Everything below is part of the beta.
126
- if (!a.beta)
127
- return this.json(res, 403, { error: "Join the beta to use the connection manager", code: "beta_required" });
128
95
  if (m === "GET" && route === "/connections")
129
96
  return this.json(res, 200, { connections: await this.p.manageConnections(a.id) });
130
97
  const c = /^\/connections\/([A-Za-z0-9_-]{1,80})$/.exec(route);
@@ -137,6 +104,13 @@ export class ManageRoutes {
137
104
  }
138
105
  if (c && m === "DELETE")
139
106
  return (await this.p.manageRevoke(a, c[1])) ? this.json(res, 200, { ok: true }) : this.json(res, 404, { error: "No such connection" });
107
+ if (m === "GET" && route === "/plugin-links")
108
+ return this.json(res, 200, { links: (await this.p.manageListPluginLinks(a.id)).map((l) => ({ id: l.id, host: l.host, createdAt: l.createdAt, lastUsedAt: l.lastUsedAt, current: l.id === linkId })) });
109
+ const pl = /^\/plugin-links\/([A-Za-z0-9_-]{1,80})$/.exec(route);
110
+ if (pl && m === "DELETE") {
111
+ const id = pl[1] === "self" ? linkId : pl[1];
112
+ return (await this.p.manageRevokePluginLink(a, id)) ? this.json(res, 200, { ok: true }) : this.json(res, 404, { error: "No such link" });
113
+ }
140
114
  if (m === "POST" && route === "/privacy") {
141
115
  const b = await this.body(req);
142
116
  if (!b || typeof b.anonymous !== "boolean")
@@ -158,9 +132,13 @@ export class ManageRoutes {
158
132
  if (!(await this.p.manageReauth(a.id, String(b.secret ?? ""))))
159
133
  return this.json(res, 403, { error: "That secret key is not right (or too many attempts)." });
160
134
  if (route === "/secret/rotate") {
135
+ // Every plugin link ends with the old key; this plugin gets a fresh token so it stays linked.
136
+ const links = await this.p.manageListPluginLinks(a.id);
137
+ const here = links.find((l) => l.id === linkId);
161
138
  const secret = await this.p.manageRotateSecret(a);
162
139
  const fresh = (await this.p.manageAccount(a.id));
163
- return this.json(res, 200, { secret }, { "Set-Cookie": this.cookie(fresh) });
140
+ const { token } = await this.p.manageLinkPlugin(fresh, { instanceHost: here?.host ?? null, paperclipUserId: here?.paperclipUserId ?? null });
141
+ return this.json(res, 200, { secret, token });
164
142
  }
165
143
  if (route === "/paperclip/disconnect") {
166
144
  await this.p.manageDisconnectPaperclip(a);
@@ -169,7 +147,7 @@ export class ManageRoutes {
169
147
  if (String(b.confirm ?? "").toLowerCase() !== a.username.toLowerCase())
170
148
  return this.json(res, 400, { error: "Type your username to confirm." });
171
149
  await this.p.manageDeleteAccount(a);
172
- return this.json(res, 200, { ok: true }, { "Set-Cookie": this.cookie(null) });
150
+ return this.json(res, 200, { ok: true });
173
151
  }
174
152
  return this.json(res, 404, { error: "Not found" });
175
153
  }
@@ -1,29 +1,42 @@
1
1
  import { USERNAME_MAX, USERNAME_MIN } from "../accounts/username.js";
2
2
  import { FAVICON_LINK } from "../site/favicon.js";
3
- import { grantableScopes, scopesUpTo } from "./scopes.js";
3
+ import { grantableScopes } from "./scopes.js";
4
4
  export const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
5
5
  /**
6
6
  * Papercliped sign-in pages. The look follows Paperclip's own sign-in screen (warm cream page, left-aligned form, olive
7
7
  * accents, no card chrome) so connecting feels native; colours were sampled from it and the enabled button is darkened so text
8
8
  * meets WCAG AA (contrast is asserted in the tests). One self-contained document per page: no external fonts, scripts or images.
9
9
  */
10
+ /**
11
+ * The website's type and colours, for the server-rendered pages. Fonts load from /fonts/ when the website is deployed with the
12
+ * bridge (web/dist); otherwise the system fallbacks are used.
13
+ */
14
+ export const FONTS_CSS = `@font-face{font-family:"Inter Variable";font-style:normal;font-display:swap;font-weight:100 900;src:url(/fonts/inter-latin-wght-normal.woff2) format("woff2-variations")}
15
+ @font-face{font-family:"Bricolage Grotesque Variable";font-style:normal;font-display:swap;font-weight:200 800;src:url(/fonts/bricolage-grotesque-latin-wght-normal.woff2) format("woff2-variations")}
16
+ @font-face{font-family:"JetBrains Mono";font-style:normal;font-display:swap;font-weight:400;src:url(/fonts/jetbrains-mono-latin-400-normal.woff2) format("woff2")}`;
17
+ export const SANS = `"Inter Variable",Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif`;
18
+ export const DISPLAY = `"Bricolage Grotesque Variable","Inter Variable",ui-sans-serif,system-ui,sans-serif`;
19
+ export const MONO = `"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace`;
20
+ /** The default "clip" theme in dark mode (same values as web/src/theme/palettes.ts). */
21
+ export const THEME_DARK = { bg: "#16150f", ink: "#f4f1d6", ink2: "#e9e4b0", muted: "#bdb99f", line: "#5c5a49", btn: "#e9e4b0", btnText: "#16150f", disabled: "#7d7a64" };
10
22
  export const THEME = { bg: "#fffddc", ink: "#0b0a07", ink2: "#47463c", muted: "#6b6959", line: "#b0a993", btn: "#47463c", btnText: "#fffddc", disabled: "#8c8a77", errBg: "#fde8e3", errInk: "#8a1f11", warnBg: "#fff1c2" };
11
- const CSS = `
12
- :root{--bg:${THEME.bg};--ink:${THEME.ink};--ink2:${THEME.ink2};--muted:${THEME.muted};--line:${THEME.line};--btn:${THEME.btn};--btntext:${THEME.btnText};--off:${THEME.disabled}}
23
+ const CSS = `${FONTS_CSS}
24
+ :root{--bg:${THEME.bg};--ink:${THEME.ink};--ink2:${THEME.ink2};--muted:${THEME.muted};--line:${THEME.line};--btn:${THEME.btn};--btntext:${THEME.btnText};--off:${THEME.disabled};color-scheme:light}
25
+ @media (prefers-color-scheme:dark){:root{--bg:${THEME_DARK.bg};--ink:${THEME_DARK.ink};--ink2:${THEME_DARK.ink2};--muted:${THEME_DARK.muted};--line:${THEME_DARK.line};--btn:${THEME_DARK.btn};--btntext:${THEME_DARK.btnText};--off:${THEME_DARK.disabled};color-scheme:dark}}
13
26
  *{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
14
- body{margin:0;min-height:100vh;background:var(--bg);color:var(--ink);font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;position:relative;overflow-x:hidden}
27
+ body{margin:0;min-height:100vh;background:var(--bg);color:var(--ink);font:15px/1.55 ${SANS};position:relative;overflow-x:hidden}
15
28
  body::before{content:"";position:fixed;right:5vw;top:5vh;width:min(26vw,300px);height:min(48vh,420px);pointer-events:none;opacity:.55;
16
29
  background-image:radial-gradient(var(--line) 1.1px,transparent 1.3px);background-size:13px 13px;-webkit-mask-image:linear-gradient(135deg,#000 0%,transparent 75%);mask-image:linear-gradient(135deg,#000 0%,transparent 75%)}
17
30
  @media (max-width:900px){body::before{display:none}}
18
31
  main{position:relative;width:min(400px,100% - 40px);margin:9vh 0 6vh max(20px,min(14vw,240px))}
19
32
  @media (max-width:900px){main{margin:6vh auto}}
20
- .brand{display:flex;align-items:center;gap:8px;font-weight:600;font-size:17px;margin-bottom:34px}.brand svg{width:20px;height:20px}
21
- h1{font-size:21px;line-height:1.25;font-weight:600;margin:0 0 6px}
33
+ .brand{display:flex;align-items:center;gap:8px;font-family:${DISPLAY};font-weight:700;font-size:19px;text-transform:lowercase;margin-bottom:34px}.brand svg{width:20px;height:20px}
34
+ h1{font-family:${DISPLAY};font-size:24px;line-height:1.2;font-weight:700;letter-spacing:-.01em;margin:0 0 6px}
22
35
  p.lede{margin:0 0 20px;color:var(--muted)}
23
36
  label.f{display:block;font-size:12px;color:var(--muted);margin:14px 0 5px}
24
37
  input[type=text],input[type=password]{width:100%;height:42px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--ink);font:inherit}
25
38
  input:focus-visible,button:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--ink2);outline-offset:2px}
26
- .btn{display:block;width:100%;height:42px;margin-top:18px;border:1px solid var(--btn);border-radius:8px;background:var(--btn);color:var(--btntext);font:inherit;font-weight:600;cursor:pointer}
39
+ .btn{display:block;width:100%;height:42px;margin-top:18px;border:1px solid var(--btn);border-radius:8px;background:var(--btn);color:var(--btntext);font:inherit;font-weight:600;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease}.btn:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(0,0,0,.12)}.btn:active{transform:translateY(0) scale(.98)}@media (prefers-reduced-motion:reduce){.btn{transition:none}.btn:hover{transform:none}}
27
40
  .btn:hover{filter:brightness(1.15)}.btn:disabled{background:var(--off);border-color:var(--off);cursor:not-allowed;filter:none}
28
41
  .btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}.btn.ghost:hover{background:rgba(71,70,60,.07);filter:none}
29
42
  .row{display:flex;gap:10px}.row .btn{margin-top:18px}
@@ -33,7 +46,7 @@ input:focus-visible,button:focus-visible,a:focus-visible,summary:focus-visible{o
33
46
  .opt{display:block;border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:8px 0;cursor:pointer}.opt:has(input:checked){border-color:var(--ink2);box-shadow:0 0 0 1px var(--ink2)}
34
47
  .opt strong{font-weight:600}.opt small{display:block;color:var(--muted);margin-top:2px;font-size:13px}
35
48
  small.hint{display:block;color:var(--muted);font-size:12.5px;margin-top:6px}
36
- code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
49
+ code,.mono{font-family:${MONO}}
37
50
  .secret{display:block;margin:14px 0 8px;padding:14px 12px;border:1px dashed var(--ink2);border-radius:8px;font-size:13px;letter-spacing:0;overflow-wrap:normal;background:rgba(255,255,255,.35);user-select:all}
38
51
  a{color:var(--ink)}
39
52
  .fine{color:var(--muted);font-size:12.5px;margin-top:22px}
@@ -42,7 +55,7 @@ ul.rules{margin:8px 0 0;padding-left:18px;color:var(--muted);font-size:13px}
42
55
  `;
43
56
  const CLIP = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`;
44
57
  function shell(title, body, nonce, script) {
45
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>${esc(title)} · Papercliped</title>${FAVICON_LINK}<style>${CSS}</style></head><body><main><div class="brand">${CLIP}<span>Papercliped</span></div>${body}</main>${script ? `<script nonce="${nonce}">${script}</script>` : ""}</body></html>`;
58
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>${esc(title)} · Papercliped</title>${FAVICON_LINK}<style>${CSS}</style></head><body><main><div class="brand">${CLIP}<span>Papercliped</span></div>${body}</main>${script ? `<script nonce="${nonce}">${script}</script>` : ""}</body></html>`;
46
59
  }
47
60
  const hidden = (rid, csrf) => `<input type="hidden" name="rid" value="${esc(rid)}"><input type="hidden" name="csrf" value="${esc(csrf)}">`;
48
61
  const errBox = (e) => (e ? `<div class="err" role="alert">${esc(e)}</div>` : "");
@@ -90,7 +103,7 @@ export function approvePage(v) {
90
103
  <div class="row"><button class="btn" name="action" value="continue">Continue</button>${cancel}</div>
91
104
  </form>`, v.nonce, poll);
92
105
  }
93
- const ANON_TEXT = "Appear anonymously in the operator's logs and panel (as a name like Ann02), and hide my Paperclip's address there.";
106
+ const ANON_TEXT = "Appear anonymously in the operator's logs and dashboard (as a name like Ann02), and hide my Paperclip's address there.";
94
107
  export function usernamePage(v) {
95
108
  return shell("Choose a username", `<h1>Choose your username</h1>
96
109
  <p class="lede">Your Paperclip is connected. Pick a username so you can log in later without approving again.</p>
@@ -128,13 +141,12 @@ export function welcomePage(v) {
128
141
  }
129
142
  const LEVELS = [
130
143
  { scope: "paperclip:read", label: "Read only", blurb: "View agents, tasks, goals, costs and reports. Cannot change anything." },
131
- { scope: "paperclip:control", label: "Full control (beta)", blurb: "Everything above, plus pause, resume or wake agents, create and update issues and goals, and comment." },
132
- { scope: "paperclip:admin", label: "Admin (advanced)", blurb: "Everything above, plus decide approvals, change budgets, terminate agents and send arbitrary API writes." },
144
+ { scope: "paperclip:control", label: "Full control", blurb: "Everything: pause, resume, wake or terminate agents, assign and update issues and goals, comment, decide approvals, set budgets and send any API request." },
133
145
  ];
134
146
  /** Final step of the account flow: pick how much the app may do. */
135
147
  export function scopePage(v) {
136
148
  const allowed = new Set(grantableScopes(v.requestedMax));
137
- const def = v.requestedMax === "paperclip:admin" ? "paperclip:control" : v.requestedMax;
149
+ const def = v.requestedMax;
138
150
  const opts = LEVELS.filter((l) => allowed.has(l.scope))
139
151
  .map((l) => `<label class="opt"><input type="radio" name="level" value="${l.scope}"${l.scope === def ? " checked" : ""}> <strong>${l.label}</strong><small>${esc(l.blurb)}</small></label>`)
140
152
  .join("");
@@ -148,15 +160,13 @@ export function scopePage(v) {
148
160
  ${opts}${where}
149
161
  <input type="hidden" name="privacy_present" value="1">
150
162
  <label class="chk"><input type="checkbox" name="anonymous"${v.anonymous ? " checked" : ""}><span>${esc(ANON_TEXT)}${v.alias ? ` <small class="hint">${v.anonymous ? "You appear as" : "You would appear as"} <strong>${esc(v.alias)}</strong>.</small>` : ""}</span></label>
151
- <input type="hidden" name="beta_present" value="1">
152
- <label class="chk"><input type="checkbox" name="beta"${v.beta ? " checked" : ""}><span>Join the beta: manage your connections (see them, change what each may do, disconnect) at <code>/manage</code>.</span></label>
153
163
  <div class="row"><button class="btn" name="action" value="allow">Allow</button>${cancel}</div>
154
164
  </form>
155
- <p class="fine">Disconnect any time from the app. Access levels are enforced by Papercliped; your Paperclip key stays encrypted on our side and is never shown to the app.</p>`);
165
+ <p class="fine">Disconnect any time from the app, or manage every connection inside Paperclip with the Papercliped plugin. Access levels are enforced by Papercliped; your Paperclip key stays encrypted on our side and is never shown to the app.</p>`);
156
166
  }
157
167
  export function consentPage(v) {
158
- const allowed = new Set(scopesUpTo(v.requestedMax));
159
- const def = v.requestedMax === "paperclip:admin" ? "paperclip:control" : v.requestedMax;
168
+ const allowed = new Set(grantableScopes());
169
+ const def = v.requestedMax;
160
170
  const opts = LEVELS.filter((l) => allowed.has(l.scope))
161
171
  .map((l) => `<label class="opt"><input type="radio" name="level" value="${l.scope}"${l.scope === def ? " checked" : ""}> <strong>${l.label}</strong><small>${esc(l.blurb)}</small></label>`)
162
172
  .join("");
@@ -429,16 +429,3 @@ export class PgStore {
429
429
  await this.pool.end();
430
430
  }
431
431
  }
432
- /**
433
- * The operator panel's data access. Connects with the read-only `panel_ro` role (docs/panel-role.sql) and reads accounts and grants
434
- * ONLY through the safe views, so it cannot see a secret hash, a sealed credential or a real username of an anonymous account.
435
- * Write methods inherited from PgStore are never called by the panel, and the role could not execute them anyway.
436
- */
437
- export class PgPanelStore extends PgStore {
438
- async countAccounts() {
439
- return num((await this.q("select count(*) as n from bridge.panel_accounts")).rows[0].n);
440
- }
441
- async liveGrantCount() {
442
- return num((await this.q("select count(*) as n from bridge.panel_grants where not revoked")).rows[0].n);
443
- }
444
- }
@@ -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";
@@ -10,7 +9,7 @@ import { Keyring, isValidCodeChallenge, randomToken, safeEqual, sha256Hex, verif
10
9
  import { approvePage, choosePage, consentPage, errorPage, instancePage, scopePage, secretPage, usernamePage, welcomePage } from "./pages.js";
11
10
  import { LoginError, PaperclipLogin } from "./paperclip-login.js";
12
11
  import { MemoryStore } from "./store.js";
13
- import { SCOPES, grantableScopes, isScope, maxRank, scopeAllows, scopesUpTo } from "./scopes.js";
12
+ import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
14
13
  const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
15
14
  const PENDING_TTL_MS = 10 * 60_000;
16
15
  const CODE_TTL_MS = 60_000;
@@ -83,7 +82,7 @@ export class OAuthProvider {
83
82
  /* telemetry only */
84
83
  }
85
84
  }
86
- /** The name logs and the panel show for an account: its alias if it opted into anonymity, else its username. */
85
+ /** The name logs and the operator dashboard show for an account: its alias if it opted into anonymity, else its username. */
87
86
  async who(accountId) {
88
87
  if (!accountId)
89
88
  return null;
@@ -222,8 +221,8 @@ export class OAuthProvider {
222
221
  /* best effort */
223
222
  }
224
223
  }
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.
224
+ // ───────────── account management (the Paperclip plugin's connection manager) ─────────────
225
+ // Everything here acts on ONE account and is reached only through ManageRoutes, with a plugin token or the secret key.
227
226
  /** Username + secret key, throttled exactly like the sign-in page (shared counters, so there is no cheaper way to guess). */
228
227
  async manageLogin(req, username, secret) {
229
228
  const typed = username.trim().slice(0, 64);
@@ -249,45 +248,21 @@ export class OAuthProvider {
249
248
  return false;
250
249
  return verifySecretKey(secret, a.secretHash);
251
250
  }
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 ──
251
+ // ── the Paperclip plugin link: the plugin signs in once with username + secret key and gets a long-lived plugin token ──
257
252
  // The token is stored as a hash on a synthetic grant (client "paperclip-plugin", NO scopes), so it appears in no tool list,
258
253
  // 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;
254
+ /** A plugin token for an account whose owner just proved the secret key. */
255
+ async manageLinkPlugin(a, meta) {
281
256
  const id = `pcb_pg_${randomToken(10)}`;
282
257
  const token = `pcb_pl_${randomToken(32)}`;
283
258
  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
259
  await this.store.putAccess(sha256Hex(token), { grantId: id, expiresAt: this.now() + 180 * DAY_MS });
285
260
  await this.event("updated", { accountId: a.id, username: displayName(a) }, "plugin_linked");
286
- return { token };
261
+ return { token, linkId: id };
287
262
  }
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_"))
263
+ /** The account (and link id) a plugin token belongs to (null if unknown, expired, revoked, or not a plugin token). */
264
+ async managePluginPrincipal(token) {
265
+ if (!token.startsWith("pcb_pl_") || token.length > 200)
291
266
  return null;
292
267
  const rec = await this.store.getAccess(sha256Hex(token));
293
268
  if (!rec || rec.expiresAt < this.now())
@@ -299,10 +274,10 @@ export class OAuthProvider {
299
274
  if (!a || a.disabled)
300
275
  return null;
301
276
  await this.store.touchGrant(g.id);
302
- return a;
277
+ return { account: a, linkId: g.id };
303
278
  }
304
279
  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 }));
280
+ 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
281
  }
307
282
  async manageRevokePluginLink(a, id) {
308
283
  const g = await this.store.getGrant(id);
@@ -315,24 +290,16 @@ export class OAuthProvider {
315
290
  manageAccount(accountId) {
316
291
  return this.store.getAccount(accountId);
317
292
  }
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
293
  async manageConnections(accountId) {
325
294
  return (await this.store.listAccountGrants(accountId))
326
295
  .filter((g) => !g.revoked && g.clientId !== "paperclip-plugin")
327
296
  .map((g) => ({ id: g.id, app: g.clientName, level: levelOf(g.scopes), createdAt: g.createdAt, lastUsedAt: g.lastUsedAt }));
328
297
  }
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. */
298
+ /** Change what a connected app may do: Read only or Full control. */
330
299
  async manageSetLevel(a, grantId, level) {
331
300
  const g = await this.store.getGrant(grantId);
332
301
  if (!g || g.revoked || g.accountId !== a.id || g.clientId === "paperclip-plugin")
333
302
  return false;
334
- if (level === "paperclip:admin" && levelOf(g.scopes) !== "paperclip:admin")
335
- return false;
336
303
  if (!(await this.store.setGrantScopes(g.id, scopesUpTo(level))))
337
304
  return false;
338
305
  await this.event("updated", { accountId: a.id, username: displayName(a) }, `level_${level.split(":")[1]}`);
@@ -352,6 +319,10 @@ export class OAuthProvider {
352
319
  async manageRotateSecret(a) {
353
320
  const secret = generateSecretKey();
354
321
  await this.store.setAccountSecret(a.id, await hashSecretKey(normalizeSecretKey(secret)));
322
+ // A new secret key means "I may be compromised": every plugin link goes too (the caller relinks the plugin it came from).
323
+ for (const g of await this.store.listAccountGrants(a.id))
324
+ if (g.clientId === "paperclip-plugin" && !g.revoked)
325
+ await this.store.revokeGrant(g.id);
355
326
  await this.event("updated", { accountId: a.id, username: displayName(a) }, "secret_rotated");
356
327
  return secret;
357
328
  }
@@ -520,7 +491,7 @@ export class OAuthProvider {
520
491
  "X-Frame-Options": "DENY",
521
492
  "X-Content-Type-Options": "nosniff",
522
493
  "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'`,
494
+ "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
495
  });
525
496
  res.end(body);
526
497
  return true;
@@ -640,7 +611,7 @@ export class OAuthProvider {
640
611
  return this.html(res, 429, errorPage("Too many requests", "Please wait a minute and try again."));
641
612
  if ((await this.store.countPending()) >= MAX_PENDING)
642
613
  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);
614
+ const asked = (q.get("scope") ?? "").split(/\s+/).map(normalizeScope).filter(isScope);
644
615
  const requestedMax = asked.length ? levelOf(asked) : "paperclip:read";
645
616
  const rid = randomToken(24);
646
617
  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 +647,7 @@ export class OAuthProvider {
676
647
  clientName: p.clientName,
677
648
  redirectHost: u.host,
678
649
  loopbackOnly: u.protocol === "http:" && LOOPBACK.has(u.hostname),
679
- requestedMax: p.requestedMax,
650
+ requestedMax: normalizeScope(p.requestedMax),
680
651
  login: this.deps.oauth.login,
681
652
  approvalUrl: ch?.approvalUrl,
682
653
  approved,
@@ -716,7 +687,7 @@ export class OAuthProvider {
716
687
  await this.event("connect_failed", null, "denied");
717
688
  return this.redirectWith(res, p.redirectUri, { error: "access_denied", error_description: "The user denied the request", state: p.state });
718
689
  }
719
- const allowed = scopesUpTo(p.requestedMax);
690
+ const allowed = grantableScopes();
720
691
  const level = (f.get("level") ?? "");
721
692
  if (!allowed.includes(level))
722
693
  return this.renderConsent(res, rid, p, false, "Choose an access level.");
@@ -805,7 +776,7 @@ export class OAuthProvider {
805
776
  const link = p.accountId ? await this.store.getLink(p.accountId) : undefined;
806
777
  const u = new URL(p.redirectUri);
807
778
  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);
779
+ 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
780
  }
810
781
  default:
811
782
  return this.page(res, status, choosePage(c), p);
@@ -1140,7 +1111,7 @@ export class OAuthProvider {
1140
1111
  if (!s)
1141
1112
  return true;
1142
1113
  const { f, p, rid } = s;
1143
- const allowed = grantableScopes(p.requestedMax);
1114
+ const allowed = grantableScopes();
1144
1115
  const level = (f.get("level") ?? "");
1145
1116
  if (!allowed.includes(level))
1146
1117
  return this.renderStage(res, rid, p, "Choose an access level.");
@@ -1156,13 +1127,6 @@ export class OAuthProvider {
1156
1127
  if (want !== !!acct.anonymous)
1157
1128
  acct = await this.setPrivacy(acct, want);
1158
1129
  }
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
1130
  const code = `pcb_ac_${randomToken(32)}`;
1167
1131
  await this.store.putCode(sha256Hex(code), {
1168
1132
  clientId: p.clientId,
@@ -1174,7 +1138,7 @@ export class OAuthProvider {
1174
1138
  instanceUrl: link.instanceUrl,
1175
1139
  sealedCredential: null, // the credential stays on the account link
1176
1140
  accountId: p.accountId,
1177
- username: displayName(acct), // grants carry the DISPLAY name: that is what audit rows and the panel see
1141
+ username: displayName(acct), // grants carry the DISPLAY name: that is what audit rows and the operator dashboard see
1178
1142
  path: p.path ?? "connect",
1179
1143
  expiresAt: this.now() + CODE_TTL_MS,
1180
1144
  });
@@ -1246,7 +1210,7 @@ export class OAuthProvider {
1246
1210
  }
1247
1211
  if (f.get("client_id") !== grant.clientId)
1248
1212
  throw new OAuthError("invalid_grant", "client_id mismatch");
1249
- const asked = (f.get("scope") ?? "").split(/\s+/).filter(Boolean);
1213
+ const asked = (f.get("scope") ?? "").split(/\s+/).filter(Boolean).map(normalizeScope);
1250
1214
  if (asked.some((s) => !isScope(s) || !scopeAllows(grant.scopes, s)))
1251
1215
  throw new OAuthError("invalid_scope", "Requested scope exceeds the original grant");
1252
1216
  return this.issue(grant);
@@ -1257,7 +1221,7 @@ export class OAuthProvider {
1257
1221
  const refresh = `pcb_rt_${randomToken(32)}`;
1258
1222
  await this.store.putAccess(sha256Hex(access), { grantId: grant.id, expiresAt: t + this.deps.oauth.accessTtlSec * 1000 });
1259
1223
  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(" ") };
1224
+ return { access_token: access, token_type: "Bearer", expires_in: this.deps.oauth.accessTtlSec, refresh_token: refresh, scope: normalizeScopes(grant.scopes).join(" ") };
1261
1225
  }
1262
1226
  // ───────────── POST /revoke (RFC 7009) ─────────────
1263
1227
  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];