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.
- package/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +64 -1
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/commands/paperclip-report.md +5 -5
- package/commands/paperclip-status.md +4 -4
- package/commands/paperclip-sync.md +5 -5
- package/dist/accounts/alias.js +1 -1
- package/dist/accounts/username.js +1 -1
- package/dist/cli.js +11 -11
- package/dist/http.js +17 -3
- package/dist/manage/routes.js +60 -82
- package/dist/oauth/pages.js +28 -18
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +27 -63
- package/dist/oauth/scopes.js +19 -16
- package/dist/openapi.js +19 -2
- package/dist/public-api/health.js +46 -0
- package/dist/public-api/openapi.js +51 -0
- package/dist/public-api/routes.js +251 -0
- package/dist/public-api/types.js +14 -0
- package/dist/server.js +4 -0
- package/dist/site/landing.js +3 -3
- package/dist/site/markdown.js +2 -1
- package/dist/site/site.js +16 -4
- package/dist/telemetry/recorder.js +1 -1
- package/dist/tools.js +48 -2
- package/dist/web/csp.js +16 -0
- package/dist/web/hosts.js +23 -0
- package/dist/web/routes.js +132 -0
- package/docs/ARCHITECTURE.md +74 -73
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -3
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +50 -34
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -65
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -20
- package/docs/least-privilege.sql +23 -23
- package/docs/legal/PRIVACY.md +37 -37
- package/docs/legal/TERMS.md +11 -11
- package/docs/oauth.md +61 -62
- package/docs/supabase-schema.sql +8 -1
- package/migrations/001_init.sql +84 -84
- package/migrations/002_audit.sql +41 -41
- package/migrations/003_accounts.sql +66 -66
- package/migrations/004_privacy_panel.sql +57 -57
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +10 -14
- package/site/docs/chatgpt.md +1 -1
- package/site/docs/cli.md +27 -0
- package/site/docs/connect-your-paperclip.md +120 -0
- package/site/docs/environment.md +64 -0
- package/site/docs/faq.md +31 -0
- package/site/docs/getting-started.md +11 -5
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +12 -21
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +32 -0
- package/site/docs/permissions.md +5 -5
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +2 -2
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +3 -3
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +2 -2
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +5 -3
- package/site/terms.md +1 -1
- package/skills/paperclip/SKILL.md +23 -23
- package/dist/demo.js +0 -144
- package/dist/manage/page.js +0 -170
- package/dist/panel/config.js +0 -61
- package/dist/panel/main.js +0 -90
- package/dist/panel/page.js +0 -553
- package/dist/panel/routes.js +0 -284
- package/dist/panel/server.js +0 -37
- package/docs/panel-role.sql +0 -26
package/dist/manage/routes.js
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
const { token } = await this.p.manageLinkPlugin(r.account, { instanceHost: host, paperclipUserId: uid });
|
|
81
|
+
return this.json(res, 200, { token });
|
|
106
82
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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
|
-
|
|
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 }
|
|
150
|
+
return this.json(res, 200, { ok: true });
|
|
173
151
|
}
|
|
174
152
|
return this.json(res, 404, { error: "Not found" });
|
|
175
153
|
}
|
package/dist/oauth/pages.js
CHANGED
|
@@ -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
|
|
3
|
+
import { grantableScopes } from "./scopes.js";
|
|
4
4
|
export const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.
|
|
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:
|
|
21
|
-
h1{font-size:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
159
|
-
const def = 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("");
|
package/dist/oauth/pg-store.js
CHANGED
|
@@ -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
|
-
}
|
package/dist/oauth/provider.js
CHANGED
|
@@ -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
|
|
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
|
|
226
|
-
// Everything here acts on ONE account and is reached only through ManageRoutes
|
|
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
|
-
|
|
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
|
|
260
|
-
async
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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) {
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
13
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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];
|