papercliped 1.1.0-beta.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +70 -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 +32 -18
- package/dist/oauth/paperclip-login.js +38 -0
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +83 -64
- 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 +36 -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>` : "");
|
|
@@ -73,6 +86,10 @@ export function instancePage(v) {
|
|
|
73
86
|
<label class="f" for="inst">Paperclip address</label>
|
|
74
87
|
<input id="inst" name="instance" type="text" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://paperclip.example.com" value="${esc(v.value ?? "")}" required>
|
|
75
88
|
<small class="hint">The bridge contacts this address to sign you in and to run what you allow. Only enter an address you own.</small>
|
|
89
|
+
<label class="f" style="display:flex;gap:10px;align-items:flex-start;margin-top:16px;font-weight:400">
|
|
90
|
+
<input id="plug" name="install_plugin" type="checkbox" value="on" style="width:auto;margin-top:3px"${v.installPlugin === false ? "" : " checked"}>
|
|
91
|
+
<span>Also install the Papercliped plugin in my Paperclip<br><small class="hint">Adds a Papercliped page to Paperclip for managing connected apps. It is installed through Paperclip's own installer, only if you are its instance admin, and you can remove it any time from Settings → Plugins.</small></span>
|
|
92
|
+
</label>
|
|
76
93
|
<div class="row"><button class="btn" name="action" value="continue">Continue</button>${cancel}</div>
|
|
77
94
|
</form>
|
|
78
95
|
<p class="fine">You'll return to <code>${esc(v.redirectHost)}</code> afterwards.</p>`);
|
|
@@ -90,7 +107,7 @@ export function approvePage(v) {
|
|
|
90
107
|
<div class="row"><button class="btn" name="action" value="continue">Continue</button>${cancel}</div>
|
|
91
108
|
</form>`, v.nonce, poll);
|
|
92
109
|
}
|
|
93
|
-
const ANON_TEXT = "Appear anonymously in the operator's logs and
|
|
110
|
+
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
111
|
export function usernamePage(v) {
|
|
95
112
|
return shell("Choose a username", `<h1>Choose your username</h1>
|
|
96
113
|
<p class="lede">Your Paperclip is connected. Pick a username so you can log in later without approving again.</p>
|
|
@@ -128,13 +145,12 @@ export function welcomePage(v) {
|
|
|
128
145
|
}
|
|
129
146
|
const LEVELS = [
|
|
130
147
|
{ 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." },
|
|
148
|
+
{ 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
149
|
];
|
|
134
150
|
/** Final step of the account flow: pick how much the app may do. */
|
|
135
151
|
export function scopePage(v) {
|
|
136
152
|
const allowed = new Set(grantableScopes(v.requestedMax));
|
|
137
|
-
const def = v.requestedMax
|
|
153
|
+
const def = v.requestedMax;
|
|
138
154
|
const opts = LEVELS.filter((l) => allowed.has(l.scope))
|
|
139
155
|
.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
156
|
.join("");
|
|
@@ -148,15 +164,13 @@ export function scopePage(v) {
|
|
|
148
164
|
${opts}${where}
|
|
149
165
|
<input type="hidden" name="privacy_present" value="1">
|
|
150
166
|
<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
167
|
<div class="row"><button class="btn" name="action" value="allow">Allow</button>${cancel}</div>
|
|
154
168
|
</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>`);
|
|
169
|
+
<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
170
|
}
|
|
157
171
|
export function consentPage(v) {
|
|
158
|
-
const allowed = new Set(
|
|
159
|
-
const def = v.requestedMax
|
|
172
|
+
const allowed = new Set(grantableScopes());
|
|
173
|
+
const def = v.requestedMax;
|
|
160
174
|
const opts = LEVELS.filter((l) => allowed.has(l.scope))
|
|
161
175
|
.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
176
|
.join("");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
2
|
const APPROVAL_PATH = /^\/cli-auth\/[0-9a-f-]{36}\?token=[A-Za-z0-9_\-.~%]{8,300}$/i;
|
|
3
|
+
/** Paperclip runs npm install server-side, which can take a while. */
|
|
4
|
+
const PLUGIN_INSTALL_TIMEOUT_MS = 120_000;
|
|
3
5
|
const TOKENISH = /^[\x21-\x7e]{8,600}$/; // printable ASCII, no whitespace
|
|
4
6
|
export class LoginError extends Error {
|
|
5
7
|
status;
|
|
@@ -80,6 +82,42 @@ export class PaperclipLogin {
|
|
|
80
82
|
const r = await json(await this.t.fetch(`${this.t.apiUrl}/cli-auth/me`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, ...this.sig() }));
|
|
81
83
|
return { userId: typeof r?.userId === "string" ? r.userId.slice(0, 200) : null };
|
|
82
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The Papercliped plugin's state in this Paperclip: absent, or installed with its version.
|
|
87
|
+
* `unsupported` = no plugin system there (older Paperclip) or the key may not manage plugins.
|
|
88
|
+
*/
|
|
89
|
+
async pluginState(token, pluginKey) {
|
|
90
|
+
const res = await this.t.fetch(`${this.t.apiUrl}/plugins`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, ...this.sig() });
|
|
91
|
+
if (res.status === 403 || res.status === 404)
|
|
92
|
+
return { state: "unsupported" };
|
|
93
|
+
const list = await json(res);
|
|
94
|
+
if (!Array.isArray(list))
|
|
95
|
+
throw new LoginError("Paperclip returned an unexpected plugin list");
|
|
96
|
+
const hit = list.find((x) => x && typeof x === "object" && x.pluginKey === pluginKey);
|
|
97
|
+
if (!hit)
|
|
98
|
+
return { state: "absent" };
|
|
99
|
+
return { state: "installed", version: typeof hit.version === "string" ? hit.version.slice(0, 40) : null };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Install one fixed npm package at one exact version through Paperclip's own installer. Only an instance
|
|
103
|
+
* admin's key is accepted there; anyone else gets a 403, reported as "denied". Nothing user-supplied reaches this call.
|
|
104
|
+
*/
|
|
105
|
+
async installPlugin(token, packageName, version) {
|
|
106
|
+
const res = await this.t.fetch(`${this.t.apiUrl}/plugins/install`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
109
|
+
body: JSON.stringify({ packageName, version }),
|
|
110
|
+
signal: AbortSignal.timeout(Math.max(this.t.timeoutMs, PLUGIN_INSTALL_TIMEOUT_MS)),
|
|
111
|
+
});
|
|
112
|
+
if (res.status === 403)
|
|
113
|
+
return "denied";
|
|
114
|
+
if (res.status === 404)
|
|
115
|
+
return "unsupported";
|
|
116
|
+
const out = await json(res);
|
|
117
|
+
if (out?.packageName !== packageName)
|
|
118
|
+
throw new LoginError("Paperclip installed something other than the Papercliped plugin");
|
|
119
|
+
return "installed";
|
|
120
|
+
}
|
|
83
121
|
/** Best effort: invalidate the board key we were issued when a grant is revoked. */
|
|
84
122
|
async revoke(token) {
|
|
85
123
|
try {
|
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
|
-
}
|