papercliped 2.2.1 → 2.4.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/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +9 -5
- package/dist/access/guard.js +17 -0
- package/dist/access/policy.js +203 -0
- package/dist/execute.js +25 -1
- package/dist/manage/routes.js +46 -2
- package/dist/mcp.js +6 -5
- package/dist/oauth/pg-store.js +13 -1
- package/dist/oauth/provider.js +50 -1
- package/dist/oauth/store.js +20 -0
- package/dist/public-api/types.js +1 -1
- package/dist/server.js +2 -2
- package/dist/site/markdown.js +26 -5
- package/dist/site/site.js +10 -1
- package/docs/CONTROL-ROOM.md +79 -0
- package/docs/SECURITY.md +1 -1
- package/docs/SITEMAP.md +3 -1
- package/docs/supabase-schema.sql +12 -1
- package/migrations/007_control_room.sql +9 -0
- package/package.json +1 -1
- package/site/docs/access-modes.md +72 -0
- package/site/docs/connect-your-paperclip.md +139 -93
- package/site/docs/control-room.md +61 -0
- package/site/docs/faq.md +16 -1
- package/site/docs/getting-started.md +2 -2
- package/site/docs/host-coolify.md +78 -0
- package/site/docs/host-fly.md +129 -0
- package/site/docs/host-railway.md +105 -0
- package/site/docs/host-render.md +79 -0
- package/site/docs/host-vps.md +202 -0
- package/site/docs/hosting.md +105 -0
- package/site/docs/limits-and-errors.md +1 -0
- package/site/docs/manage-api.md +12 -1
- package/site/docs/paperclip-plugin.md +21 -10
- package/site/docs/permissions.md +8 -0
- package/site/docs/prompts.md +349 -0
- package/site/docs/public-api.md +1 -1
- package/site/docs/signup.md +1 -1
- package/site/docs/tools.md +35 -35
- package/site/docs/troubleshooting.md +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.4.0 — the control room: manage every session, agent and tool inside Paperclip
|
|
6
|
+
|
|
7
|
+
- **The control room.** The Papercliped plugin now opens a control room inside Paperclip, in Paperclip's own design: a left menu that replaces the company menu (Overview, Sessions, Agents, Tools, Activity, Settings), the way Paperclip's own agent page does. See every connected AI app, what each may use, which agents it applies to, and what it has done. New guides: **The control room** and **API only, Full or Agent only**.
|
|
8
|
+
- **An access switch: API only, Full or Agent only.** One three-position switch (with a sliding knob, keyboard support and a small version for the dashboard) decides how AI apps may work with your Paperclip. **API only**: look and control Paperclip directly (pause, budgets, approvals, goals) but don't hand work to agents. **Agent only**: look and work through agents (create, assign and comment on issues, wake an agent) but no direct control. **Full** (the default, so nothing changes until you choose): both. It takes effect on the app's next request.
|
|
9
|
+
- **Set any agent apart.** On the Agents page, or on a new **Papercliped tab on every agent's page**, give an agent its own mode, or turn it **Off**: AI apps can't see it, touch it, or see what is assigned to it. Off agents are removed from lists and reports before the report is written, so they can't leak into a summary.
|
|
10
|
+
- **Sessions you can manage.** Every connected app is a session: give it a name, change its level, choose exactly which of the tools it may use (grouped as Look, Work through agents and Direct control), and choose whether it applies to everyone or only selected agents. A session limited to selected agents works only with those agents; it can't use views across every agent, and the page says so.
|
|
11
|
+
- **Blocked calls are visible.** A refused call returns a plain message naming the setting and where to change it (with a `policy` code in the API), and shows as **Blocked** in the new Activity page, which lists the newest calls, their session, tool and result, never what was sent or returned. AI apps are no longer shown tools that can never run for their session.
|
|
12
|
+
- **A dashboard widget** with the access switch in small, the number of sessions and how many calls were blocked today.
|
|
13
|
+
- **Tool reference shows each tool's kind** (a new "Works in" column), and `paperclip_api_request` is described correctly: `GET` works in every mode.
|
|
14
|
+
- **For API users:** new management endpoints `GET /sessions`, `POST|DELETE /sessions/:id`, `GET|POST|PUT /policy`, `GET /tools` and `GET /activity`; a new error class `policy_blocked` in the public API's aggregate error classes; migration `007_control_room.sql` adds two nullable columns and needs no backfill.
|
|
15
|
+
- **Update note.** Paperclip won't update a plugin to a version that asks for new permissions without an admin's approval, so to get the control room remove the Papercliped plugin in Settings → Plugins and install it again (or connect your Paperclip again with the install box ticked). Your account, sessions and settings are kept.
|
|
16
|
+
- **The Papercliped plugin now looks like part of Paperclip.** Its sidebar entry has the same paperclip icon, spacing, type, hover and highlighted-when-open look as Tasks, Artifacts and Cases, and the page uses Paperclip’s own cards, inputs, buttons, tabs and text styles, so it follows your theme (light, dark, seasonal) instead of imitating it.
|
|
17
|
+
- **Fixed: the Papercliped page inside Paperclip no longer invites your browser to fill in your Paperclip login.** The page shares an address with Paperclip’s own sign-in, so browsers offered your saved Paperclip email and password in the "Link your Papercliped account" fields, one click from sending that password to Papercliped as a "secret key". Those fields (and the confirm boxes) now tell the browser not to autofill. If you linked an account on 2.2.1, nothing needs doing; if a saved login was ever filled in, do not press Link.
|
|
18
|
+
|
|
19
|
+
## v2.3.0 — host your Paperclip anywhere, and a prompt gallery
|
|
20
|
+
|
|
21
|
+
- **Host your Paperclip: a step-by-step guide for every option.** A new docs section takes you from nothing to a running Paperclip with a public `https://` address, connected to Papercliped and answering your first prompt. It covers your own computer with a free tunnel (Cloudflare Tunnel, Tailscale Funnel, ngrok), a VPS with Docker and Caddy (Hetzner, DigitalOcean, Linode, Oracle's free tier), Railway, Render, Fly.io and Coolify, with a comparison of cost and effort. Every guide uses the official Paperclip image and the settings current Paperclip needs: authenticated mode with public exposure, your public URL, the two secrets, a persistent disk at `/paperclip`, creating the first admin with `paperclipai auth bootstrap-ceo`, and signing in your agents with API keys or a subscription. They also cover updates and backups. Hosts that sleep or have no disk are flagged as unsuitable.
|
|
22
|
+
- **Prompt gallery.** More than 40 ready-to-copy prompts at papercliped.co/prompts and in the docs (Guides → Prompt gallery). Run Paperclip in plain words instead of clicking through the dashboard: catch up in the morning, hand out issues, wake an agent and follow its run, answer approvals, set budgets and get reports. Each prompt shows the access level it needs and the tools it uses. You can search and filter by category or level.
|
|
23
|
+
- **Docs fixes:** the setup guide now uses Paperclip's real setting names and the current health check fields. Troubleshooting covers "hostname not allowed", connections that stop after Paperclip's 30-day key expiry (reconnect to fix), and agents stopping on restarts. The FAQ explains how Papercliped relates to Paperclip's new built-in MCP endpoint. Numbered steps with code blocks, and quotes, now render properly in the docs.
|
|
24
|
+
|
|
5
25
|
## v2.2.1 — the Papercliped plugin now installs in Paperclip
|
|
6
26
|
|
|
7
27
|
- **Fixed: the Papercliped plugin could not be installed in Paperclip.** Paperclip rejected it with a bare "400" because the sidebar entry declared a page route, which Paperclip only allows on pages. Installing it, automatically while connecting or by hand from Settings → Plugins, now works. The plugin’s tests now run Paperclip’s own manifest check so this can’t come back. If you ticked the box while connecting on 2.2.0, nothing was installed: connect again, or install it from Settings → Plugins.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Free and open source. Website: [papercliped.co](https://papercliped.co) · Docs:
|
|
|
12
12
|
|
|
13
13
|
## Add it to your AI app
|
|
14
14
|
|
|
15
|
-
You need a Paperclip with a public `https` address
|
|
15
|
+
You need a Paperclip with a public `https` address. Don't have one yet? [Host your Paperclip](https://docs.papercliped.co/hosting) has step-by-step guides for your own computer (free tunnel), a VPS, Railway, Render, Fly.io and Coolify. The first time you connect, you approve the request inside your own Paperclip, pick a username, save the secret key you're shown once, and choose **Read only** or **Full control**.
|
|
16
16
|
|
|
17
17
|
The connector address for every app is:
|
|
18
18
|
|
|
@@ -80,15 +80,19 @@ Add `--env PAPERCLIP_READ_ONLY=1` for reports and sync only.
|
|
|
80
80
|
|
|
81
81
|
## What you can ask
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
- "
|
|
83
|
+
Talk to your AI app instead of clicking through the dashboard:
|
|
84
|
+
|
|
85
|
+
- "Catch me up on my Paperclip." · "Which agents are over budget?"
|
|
86
|
+
- "Give the Web Engineer an issue to fix the signup page, and wake them." · "Wake the CEO, wait until they finish, then summarise."
|
|
87
|
+
- "Approve the hire request." · "Weekly report, please."
|
|
88
|
+
|
|
89
|
+
More than 40 ready-to-copy prompts: [prompt gallery](https://papercliped.co/prompts).
|
|
86
90
|
|
|
87
91
|
There are 30 tools ([full list](https://docs.papercliped.co/tools)). **Read only** can look at agents, issues, goals, costs and reports. **Full control** can use every tool. The AI asks before anything that can't be undone, and you can change the level or disconnect at any time.
|
|
88
92
|
|
|
89
93
|
## Manage your connections in Paperclip
|
|
90
94
|
|
|
91
|
-
Install the **Papercliped plugin for Paperclip** (npm `papercliped-paperclip-plugin`, from **Settings → Plugins** in Paperclip). It adds a
|
|
95
|
+
Install the **Papercliped plugin for Paperclip** (npm `papercliped-paperclip-plugin`, from **Settings → Plugins** in Paperclip). It adds a **control room**: see every connected app, choose which tools each may use and which agents it applies to, set **API only / Full / Agent only** for every agent or one at a time (or turn an agent off), read what was blocked, go anonymous, and look after your account. [Plugin guide](https://docs.papercliped.co/paperclip-plugin) · [The control room](https://docs.papercliped.co/control-room) · [API only, Full or Agent only](https://docs.papercliped.co/access-modes).
|
|
92
96
|
|
|
93
97
|
## Security and privacy
|
|
94
98
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { redactAgents } from "./policy.js";
|
|
2
|
+
/**
|
|
3
|
+
* A view of the user's Paperclip in which some agents do not exist. Every response is stripped of those agents before a tool
|
|
4
|
+
* sees it, so reports, lists and syncs are all consistent: a report built from this client cannot name a hidden agent,
|
|
5
|
+
* because it never received one. Anything the server already summed (totals) is left as it is.
|
|
6
|
+
*/
|
|
7
|
+
export function guardClient(client, hidden) {
|
|
8
|
+
if (hidden.size === 0)
|
|
9
|
+
return client;
|
|
10
|
+
const g = Object.create(client);
|
|
11
|
+
const request = async (...args) => redactAgents(await client.request(...args), hidden);
|
|
12
|
+
g.request = request;
|
|
13
|
+
g.get = ((path, query) => request("GET", path, { query }));
|
|
14
|
+
g.post = ((path, body) => request("POST", path, { body: body ?? {} }));
|
|
15
|
+
g.patch = ((path, body) => request("PATCH", path, { body }));
|
|
16
|
+
return g;
|
|
17
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export const MODES = ["api", "full", "agent"];
|
|
2
|
+
export const isMode = (v) => typeof v === "string" && MODES.includes(v);
|
|
3
|
+
export const isAgentMode = (v) => v === "off" || isMode(v);
|
|
4
|
+
export const DEFAULT_ACCOUNT_POLICY = { mode: "full", agents: {} };
|
|
5
|
+
export const DEFAULT_SESSION_POLICY = { label: null, tools: null, agents: null };
|
|
6
|
+
/** Hands work to an agent. Everything else that changes something is direct control of Paperclip. */
|
|
7
|
+
const AGENT_WORK = new Set(["paperclip_create_issue", "paperclip_update_issue", "paperclip_comment_issue", "paperclip_wake_agent"]);
|
|
8
|
+
/** Which surface a call is on. The raw API tool is read for GET and direct control for anything else. */
|
|
9
|
+
export function surfaceOf(tool, input = {}) {
|
|
10
|
+
if (tool.name === "paperclip_api_request")
|
|
11
|
+
return input.method === "GET" ? "read" : "api";
|
|
12
|
+
if (tool.access === "read")
|
|
13
|
+
return "read";
|
|
14
|
+
return AGENT_WORK.has(tool.name) ? "agent" : "api";
|
|
15
|
+
}
|
|
16
|
+
/** The surface a tool is on when called in its usual way (what the control room shows). */
|
|
17
|
+
export const surfaceOfTool = (tool) => surfaceOf(tool, {});
|
|
18
|
+
export const surfaceAllowed = (mode, surface) => surface === "read" || mode === "full" || (mode === "api" && surface === "api") || (mode === "agent" && surface === "agent");
|
|
19
|
+
export const MODE_LABEL = { api: "API only", full: "Full", agent: "Agent only", off: "Off" };
|
|
20
|
+
// ───────────── validation of what the manage API receives ─────────────
|
|
21
|
+
export class PolicyInputError extends Error {
|
|
22
|
+
}
|
|
23
|
+
const ID = /^[A-Za-z0-9._:-]{1,80}$/;
|
|
24
|
+
const MAX_AGENTS = 500;
|
|
25
|
+
/** A strict, bounded copy of untrusted input. Unknown keys are dropped; anything invalid is refused. */
|
|
26
|
+
export function parseAccountPolicy(raw) {
|
|
27
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
28
|
+
const mode = o.mode === undefined ? DEFAULT_ACCOUNT_POLICY.mode : o.mode;
|
|
29
|
+
if (!isMode(mode))
|
|
30
|
+
throw new PolicyInputError("mode must be api, full or agent");
|
|
31
|
+
const agents = {};
|
|
32
|
+
const src = o.agents === undefined ? {} : o.agents;
|
|
33
|
+
if (!src || typeof src !== "object" || Array.isArray(src))
|
|
34
|
+
throw new PolicyInputError("agents must be an object of agent id to mode");
|
|
35
|
+
const entries = Object.entries(src);
|
|
36
|
+
if (entries.length > MAX_AGENTS)
|
|
37
|
+
throw new PolicyInputError(`at most ${MAX_AGENTS} agents`);
|
|
38
|
+
for (const [id, m] of entries) {
|
|
39
|
+
if (!ID.test(id))
|
|
40
|
+
throw new PolicyInputError("invalid agent id");
|
|
41
|
+
if (!isAgentMode(m))
|
|
42
|
+
throw new PolicyInputError("an agent's mode must be api, full, agent or off");
|
|
43
|
+
if (m !== mode)
|
|
44
|
+
agents[id] = m; // an override equal to the default is no override
|
|
45
|
+
}
|
|
46
|
+
return { mode, agents };
|
|
47
|
+
}
|
|
48
|
+
export function parseSessionPolicy(raw, knownTools) {
|
|
49
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
50
|
+
let label = null;
|
|
51
|
+
if (o.label !== undefined && o.label !== null) {
|
|
52
|
+
if (typeof o.label !== "string")
|
|
53
|
+
throw new PolicyInputError("label must be text");
|
|
54
|
+
label = o.label.replace(/\s+/g, " ").trim().slice(0, 60) || null;
|
|
55
|
+
}
|
|
56
|
+
const list = (v, what, ok) => {
|
|
57
|
+
if (v === undefined || v === null)
|
|
58
|
+
return null;
|
|
59
|
+
if (!Array.isArray(v))
|
|
60
|
+
throw new PolicyInputError(`${what} must be a list, or null for all`);
|
|
61
|
+
if (v.length > MAX_AGENTS)
|
|
62
|
+
throw new PolicyInputError(`too many ${what}`);
|
|
63
|
+
const out = [...new Set(v.map(String))];
|
|
64
|
+
if (!out.every(ok))
|
|
65
|
+
throw new PolicyInputError(`invalid entry in ${what}`);
|
|
66
|
+
return out;
|
|
67
|
+
};
|
|
68
|
+
const tools = list(o.tools, "tools", (s) => knownTools.has(s));
|
|
69
|
+
const agents = list(o.agents, "agents", (s) => ID.test(s));
|
|
70
|
+
if (tools && tools.length === 0)
|
|
71
|
+
throw new PolicyInputError("choose at least one tool, or allow all");
|
|
72
|
+
if (agents && agents.length === 0)
|
|
73
|
+
throw new PolicyInputError("choose at least one agent, or apply to everyone");
|
|
74
|
+
return { label, tools, agents };
|
|
75
|
+
}
|
|
76
|
+
/** Stored JSON back to a policy, tolerating older or damaged rows by falling back to the safe default. */
|
|
77
|
+
export function readAccountPolicy(stored) {
|
|
78
|
+
try {
|
|
79
|
+
return parseAccountPolicy(typeof stored === "string" ? JSON.parse(stored) : stored);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { ...DEFAULT_ACCOUNT_POLICY, agents: {} };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function readSessionPolicy(stored, knownTools) {
|
|
86
|
+
if (stored === null || stored === undefined)
|
|
87
|
+
return { ...DEFAULT_SESSION_POLICY };
|
|
88
|
+
try {
|
|
89
|
+
return parseSessionPolicy(typeof stored === "string" ? JSON.parse(stored) : stored, knownTools);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { ...DEFAULT_SESSION_POLICY };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const CONTROL_ROOM = "Change it in the Papercliped page inside Paperclip.";
|
|
96
|
+
/** Agent ids a call names, wherever the tool takes them (a target, an assignee, an owner, or a filter). */
|
|
97
|
+
export function targetsOf(input) {
|
|
98
|
+
const ids = [input.agentId, input.assigneeAgentId, input.ownerAgentId].filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
99
|
+
return [...new Set(ids.map((s) => s.trim()))];
|
|
100
|
+
}
|
|
101
|
+
/** Tools that act on an issue, whose agent is whoever it is assigned to (not named in the call). */
|
|
102
|
+
const BY_ISSUE = new Set(["paperclip_get_issue", "paperclip_update_issue", "paperclip_comment_issue"]);
|
|
103
|
+
/** Tools a session limited to selected agents may still use without naming an agent. */
|
|
104
|
+
const OPEN_TO_LIMITED = new Set(["papercliped_service_status", "paperclip_list_companies", "paperclip_list_agents"]);
|
|
105
|
+
/** Does this call need the issue's current assignee looked up before it can be judged? */
|
|
106
|
+
export function needsIssueAgent(p, tool) {
|
|
107
|
+
return BY_ISSUE.has(tool.name) && (p.session.agents !== null || Object.keys(p.account.agents).length > 0);
|
|
108
|
+
}
|
|
109
|
+
/** Agents Papercliped may not touch at all: hidden from every result, refused as a target. */
|
|
110
|
+
export const hiddenAgents = (a) => Object.entries(a.agents).filter(([, m]) => m === "off").map(([id]) => id);
|
|
111
|
+
/**
|
|
112
|
+
* Decide one call. `issueAgent` is the assignee of the issue the call names (null = unassigned, undefined = not looked up).
|
|
113
|
+
* Returns null when the call may go ahead, or why not.
|
|
114
|
+
*/
|
|
115
|
+
export function evaluate(p, tool, input, issueAgent) {
|
|
116
|
+
const { account, session } = p;
|
|
117
|
+
const label = tool.title || tool.name;
|
|
118
|
+
if (session.tools && !session.tools.includes(tool.name)) {
|
|
119
|
+
return { code: "tool_not_allowed", message: `This session isn't allowed to use "${label}". ${CONTROL_ROOM}` };
|
|
120
|
+
}
|
|
121
|
+
const surface = surfaceOf(tool, input);
|
|
122
|
+
const targets = targetsOf(input);
|
|
123
|
+
if (typeof issueAgent === "string")
|
|
124
|
+
targets.push(issueAgent);
|
|
125
|
+
for (const a of new Set(targets)) {
|
|
126
|
+
const eff = account.agents[a] ?? account.mode;
|
|
127
|
+
if (eff === "off")
|
|
128
|
+
return { code: "agent_off", message: `Papercliped isn't allowed to use that agent. ${CONTROL_ROOM}` };
|
|
129
|
+
if (!surfaceAllowed(eff, surface))
|
|
130
|
+
return { code: "mode_blocks", message: `That agent is set to "${MODE_LABEL[eff]}", which doesn't allow "${label}". ${CONTROL_ROOM}` };
|
|
131
|
+
if (session.agents && !session.agents.includes(a))
|
|
132
|
+
return { code: "agent_not_in_session", message: `This session only applies to selected agents, and that one isn't included. ${CONTROL_ROOM}` };
|
|
133
|
+
}
|
|
134
|
+
if (targets.length === 0 && !surfaceAllowed(account.mode, surface)) {
|
|
135
|
+
return { code: "mode_blocks", message: `Papercliped is set to "${MODE_LABEL[account.mode]}", which doesn't allow "${label}". ${CONTROL_ROOM}` };
|
|
136
|
+
}
|
|
137
|
+
if (session.agents && targets.length === 0 && !OPEN_TO_LIMITED.has(tool.name)) {
|
|
138
|
+
const why = BY_ISSUE.has(tool.name) || tool.name === "paperclip_create_issue" ? "needs an issue assigned to one of its agents" : "looks across every agent";
|
|
139
|
+
return { code: "needs_all_agents", message: `This session only applies to selected agents, and "${label}" ${why}. ${CONTROL_ROOM}` };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** For a session limited to selected agents: the agent list shows only those. */
|
|
144
|
+
export function narrowAgentList(result, allowed) {
|
|
145
|
+
const keep = (x) => !!x && typeof x === "object" && allowed.includes(String(x.id));
|
|
146
|
+
if (Array.isArray(result))
|
|
147
|
+
return result.filter(keep);
|
|
148
|
+
if (result && typeof result === "object" && Array.isArray(result.agents)) {
|
|
149
|
+
return { ...result, agents: result.agents.filter(keep) };
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
const AGENT_REFS = ["id", "agentId", "assigneeAgentId", "ownerAgentId", "executorAgentId", "createdByAgentId"];
|
|
154
|
+
/**
|
|
155
|
+
* Remove every record that belongs to one of the hidden agents, wherever it appears in a response: list items, nested
|
|
156
|
+
* rows, and keys named by agent id. Totals the server already summed are left as they are.
|
|
157
|
+
*/
|
|
158
|
+
export function redactAgents(value, hidden) {
|
|
159
|
+
if (hidden.size === 0)
|
|
160
|
+
return value;
|
|
161
|
+
const belongs = (x) => {
|
|
162
|
+
if (!x || typeof x !== "object")
|
|
163
|
+
return false;
|
|
164
|
+
const o = x;
|
|
165
|
+
return AGENT_REFS.some((k) => typeof o[k] === "string" && hidden.has(o[k]));
|
|
166
|
+
};
|
|
167
|
+
const walk = (v) => {
|
|
168
|
+
if (Array.isArray(v))
|
|
169
|
+
return v.filter((x) => !belongs(x)).map(walk);
|
|
170
|
+
if (v && typeof v === "object") {
|
|
171
|
+
const out = {};
|
|
172
|
+
for (const [k, x] of Object.entries(v)) {
|
|
173
|
+
if (hidden.has(k) || belongs(x))
|
|
174
|
+
continue;
|
|
175
|
+
out[k] = walk(x);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
return v;
|
|
180
|
+
};
|
|
181
|
+
return walk(value);
|
|
182
|
+
}
|
|
183
|
+
const takesAgent = (tool) => {
|
|
184
|
+
const shape = tool.schema.shape ?? {};
|
|
185
|
+
return "agentId" in shape || "assigneeAgentId" in shape || "ownerAgentId" in shape;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Could this tool ever run for this session? A tool that can never run is not advertised to the AI, so it doesn't try it.
|
|
189
|
+
* (Whether a particular call may run is `evaluate`'s answer.)
|
|
190
|
+
*/
|
|
191
|
+
export function canEverRun(p, tool) {
|
|
192
|
+
if (p.session.tools && !p.session.tools.includes(tool.name))
|
|
193
|
+
return false;
|
|
194
|
+
if (tool.name !== "paperclip_api_request") {
|
|
195
|
+
const surface = surfaceOfTool(tool);
|
|
196
|
+
const modes = [p.account.mode, ...Object.values(p.account.agents).filter((m) => m !== "off")];
|
|
197
|
+
if (!modes.some((m) => surfaceAllowed(m, surface)))
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
if (p.session.agents)
|
|
201
|
+
return OPEN_TO_LIMITED.has(tool.name) || BY_ISSUE.has(tool.name) || tool.name === "paperclip_create_issue" || takesAgent(tool);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
package/dist/execute.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { guardClient } from "./access/guard.js";
|
|
2
|
+
import { evaluate, hiddenAgents, narrowAgentList, needsIssueAgent } from "./access/policy.js";
|
|
1
3
|
import { PaperclipApiError } from "./client.js";
|
|
2
4
|
import { ToolInputError } from "./errors.js";
|
|
3
5
|
import { requiredScope, scopeAllows } from "./oauth/scopes.js";
|
|
@@ -53,8 +55,30 @@ export async function executeTool(client, name, rawInput, opts = {}) {
|
|
|
53
55
|
if (client.config.readOnly && mutation) {
|
|
54
56
|
return done({ ok: false, status: 403, error: "Bridge is in read-only mode (PAPERCLIP_READ_ONLY); this action was not sent to Paperclip." }, "read_only");
|
|
55
57
|
}
|
|
58
|
+
let runWith = client;
|
|
59
|
+
const policy = opts.policy;
|
|
60
|
+
if (policy) {
|
|
61
|
+
// An issue's agent is whoever it is assigned to; look it up once, only when the answer can change the decision.
|
|
62
|
+
let issueAgent;
|
|
63
|
+
if (needsIssueAgent(policy, tool) && typeof parsed.data.issueId === "string") {
|
|
64
|
+
try {
|
|
65
|
+
const issue = await client.get(`/issues/${encodeURIComponent(parsed.data.issueId)}`);
|
|
66
|
+
issueAgent = typeof issue?.assigneeAgentId === "string" ? issue.assigneeAgentId : null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
issueAgent = undefined; // can't tell: a session limited to selected agents is refused below, anything else lets the tool report the real error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const denial = evaluate(policy, tool, parsed.data, issueAgent);
|
|
73
|
+
if (denial)
|
|
74
|
+
return done({ ok: false, status: 403, error: { message: denial.message, policy: denial.code } }, "policy_blocked");
|
|
75
|
+
runWith = guardClient(client, new Set(hiddenAgents(policy.account)));
|
|
76
|
+
}
|
|
56
77
|
try {
|
|
57
|
-
|
|
78
|
+
let result = await runTimed(timer, () => tool.run(runWith, parsed.data));
|
|
79
|
+
if (policy?.session.agents && tool.name === "paperclip_list_agents")
|
|
80
|
+
result = narrowAgentList(result, policy.session.agents);
|
|
81
|
+
return done({ ok: true, result });
|
|
58
82
|
}
|
|
59
83
|
catch (err) {
|
|
60
84
|
if (err instanceof ToolInputError)
|
package/dist/manage/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PolicyInputError } from "../access/policy.js";
|
|
1
2
|
import { displayName } from "../accounts/alias.js";
|
|
2
3
|
const HEADERS = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer" };
|
|
3
4
|
/** A DNS name or IPv4 literal, optionally with a port; undefined when it is anything else. */
|
|
@@ -92,8 +93,51 @@ export class ManageRoutes {
|
|
|
92
93
|
const link = await this.p.manageInstanceLabel(a.id);
|
|
93
94
|
return this.json(res, 200, { name: displayName(a), anonymous: !!a.anonymous, alias: a.alias ?? null, paperclip: link.label, connected: link.connected, createdAt: a.createdAt });
|
|
94
95
|
}
|
|
95
|
-
if (m === "GET" && route === "/connections")
|
|
96
|
-
|
|
96
|
+
if (m === "GET" && (route === "/connections" || route === "/sessions")) {
|
|
97
|
+
const list = await this.p.manageConnections(a.id);
|
|
98
|
+
return this.json(res, 200, route === "/sessions" ? { sessions: list } : { connections: list });
|
|
99
|
+
}
|
|
100
|
+
// The control room: the access switch, per-session limits, the tool list and recent calls.
|
|
101
|
+
if (route === "/policy" && (m === "GET" || m === "POST" || m === "PUT")) {
|
|
102
|
+
if (m === "GET")
|
|
103
|
+
return this.json(res, 200, await this.p.manageGetPolicy(a));
|
|
104
|
+
const b = await this.body(req);
|
|
105
|
+
if (!b)
|
|
106
|
+
return this.json(res, 400, { error: "Send JSON" });
|
|
107
|
+
try {
|
|
108
|
+
return this.json(res, 200, await this.p.manageSetPolicy(a, b));
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return e instanceof PolicyInputError ? this.json(res, 400, { error: e.message }) : this.json(res, 500, { error: "Could not save the access settings" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (m === "GET" && route === "/tools")
|
|
115
|
+
return this.json(res, 200, { tools: this.p.manageTools() });
|
|
116
|
+
if (m === "GET" && route === "/activity") {
|
|
117
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
118
|
+
if (session !== undefined && !/^[A-Za-z0-9_-]{1,80}$/.test(session))
|
|
119
|
+
return this.json(res, 400, { error: "Invalid session" });
|
|
120
|
+
const limit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
121
|
+
return this.json(res, 200, { calls: await this.p.manageActivity(a, { session, limit: Number.isFinite(limit) ? limit : 50 }) });
|
|
122
|
+
}
|
|
123
|
+
const sess = /^\/sessions\/([A-Za-z0-9_-]{1,80})$/.exec(route);
|
|
124
|
+
if (sess && m === "POST") {
|
|
125
|
+
const b = await this.body(req);
|
|
126
|
+
if (!b)
|
|
127
|
+
return this.json(res, 400, { error: "Send JSON" });
|
|
128
|
+
const level = b.level === undefined ? undefined : b.level === "read" ? "paperclip:read" : b.level === "control" ? "paperclip:control" : null;
|
|
129
|
+
if (level === null)
|
|
130
|
+
return this.json(res, 400, { error: "level must be read or control" });
|
|
131
|
+
try {
|
|
132
|
+
const next = await this.p.manageSetSession(a, sess[1], { label: b.label, tools: b.tools, agents: b.agents, level });
|
|
133
|
+
return next ? this.json(res, 200, next) : this.json(res, 404, { error: "No such session" });
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return e instanceof PolicyInputError ? this.json(res, 400, { error: e.message }) : this.json(res, 500, { error: "Could not save the session" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (sess && m === "DELETE")
|
|
140
|
+
return (await this.p.manageRevoke(a, sess[1])) ? this.json(res, 200, { ok: true }) : this.json(res, 404, { error: "No such session" });
|
|
97
141
|
const c = /^\/connections\/([A-Za-z0-9_-]{1,80})$/.exec(route);
|
|
98
142
|
if (c && m === "POST") {
|
|
99
143
|
const b = await this.body(req);
|
package/dist/mcp.js
CHANGED
|
@@ -2,19 +2,20 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { PaperclipClient } from "./client.js";
|
|
3
3
|
import { readConfig } from "./config.js";
|
|
4
4
|
import { executeTool, renderForModel } from "./execute.js";
|
|
5
|
+
import { canEverRun } from "./access/policy.js";
|
|
5
6
|
import { requiredScope, scopeAllows } from "./oauth/scopes.js";
|
|
6
7
|
import { tools } from "./tools.js";
|
|
7
8
|
export { VERSION as SERVER_VERSION } from "./version.js";
|
|
8
9
|
import { VERSION } from "./version.js";
|
|
9
10
|
/** Tools above the caller's scope are not advertised (they would only fail). The raw API tool stays: its GETs need only read. */
|
|
10
|
-
export function visibleTools(scopes) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return
|
|
11
|
+
export function visibleTools(scopes, policy) {
|
|
12
|
+
const byScope = !scopes ? tools : tools.filter((t) => t.name === "paperclip_api_request" || scopeAllows(scopes, requiredScope(t)));
|
|
13
|
+
// Tools the session or the account switch can never allow are not advertised either.
|
|
14
|
+
return policy ? byScope.filter((t) => canEverRun(policy, t)) : byScope;
|
|
14
15
|
}
|
|
15
16
|
export function createMcpServer(config = readConfig(), client = new PaperclipClient(config), exec = {}) {
|
|
16
17
|
const server = new McpServer({ name: "papercliped", version: VERSION });
|
|
17
|
-
for (const tool of visibleTools(exec.scopes)) {
|
|
18
|
+
for (const tool of visibleTools(exec.scopes, exec.policy)) {
|
|
18
19
|
server.registerTool(tool.name, {
|
|
19
20
|
title: tool.title,
|
|
20
21
|
description: tool.description,
|
package/dist/oauth/pg-store.js
CHANGED
|
@@ -28,6 +28,7 @@ const toGrant = (r) => ({
|
|
|
28
28
|
createdAt: num(r.created_at),
|
|
29
29
|
lastUsedAt: num(r.last_used_at),
|
|
30
30
|
revoked: r.revoked,
|
|
31
|
+
policy: r.policy ?? null,
|
|
31
32
|
});
|
|
32
33
|
/**
|
|
33
34
|
* Postgres-backed Store (schema `bridge`, see migrations/001_init.sql). Safe to share between several bridge
|
|
@@ -174,7 +175,7 @@ export class PgStore {
|
|
|
174
175
|
}
|
|
175
176
|
// ───────────── accounts ─────────────
|
|
176
177
|
static toAccount(x) {
|
|
177
|
-
return { id: x.id, username: x.username, usernameKey: x.username_key, secretHash: x.secret_hash, createdAt: num(x.created_at), lastLoginAt: x.last_login_at == null ? null : num(x.last_login_at), disabled: x.disabled, anonymous: !!x.anonymous, alias: x.alias ?? null, beta: !!x.beta };
|
|
178
|
+
return { id: x.id, username: x.username, usernameKey: x.username_key, secretHash: x.secret_hash, createdAt: num(x.created_at), lastLoginAt: x.last_login_at == null ? null : num(x.last_login_at), disabled: x.disabled, anonymous: !!x.anonymous, alias: x.alias ?? null, beta: !!x.beta, policy: x.policy ?? null };
|
|
178
179
|
}
|
|
179
180
|
static toLink(x) {
|
|
180
181
|
return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
|
|
@@ -199,6 +200,17 @@ export class PgStore {
|
|
|
199
200
|
async setGrantScopes(id, scopes) {
|
|
200
201
|
return ((await this.q("update bridge.grants set scopes = $2 where id = $1 and not revoked", [id, scopes])).rowCount ?? 0) === 1;
|
|
201
202
|
}
|
|
203
|
+
async setGrantPolicy(id, policy) {
|
|
204
|
+
return ((await this.q("update bridge.grants set policy = $2 where id = $1 and not revoked", [id, policy === null ? null : JSON.stringify(policy)])).rowCount ?? 0) === 1;
|
|
205
|
+
}
|
|
206
|
+
async setAccountPolicy(id, policy) {
|
|
207
|
+
return ((await this.q("update bridge.accounts set policy = $2 where id = $1", [id, JSON.stringify(policy)])).rowCount ?? 0) === 1;
|
|
208
|
+
}
|
|
209
|
+
async recentCallsForGrants(grantIds, limit) {
|
|
210
|
+
if (grantIds.length === 0)
|
|
211
|
+
return [];
|
|
212
|
+
return (await this.q("select * from bridge.audit_events where kind = 'tool' and grant_id = any($1) order by id desc limit $2", [[...grantIds], limit])).rows.map(PgStore.toRow);
|
|
213
|
+
}
|
|
202
214
|
async setAccountPrivacy(id, c) {
|
|
203
215
|
const cl = await this.pool.connect();
|
|
204
216
|
try {
|
package/dist/oauth/provider.js
CHANGED
|
@@ -8,6 +8,8 @@ import { UnsafeUrlError, createSafeFetch, parseInstanceUrl } from "../net/safe-f
|
|
|
8
8
|
import { Keyring, isValidCodeChallenge, randomToken, safeEqual, sha256Hex, verifyPkce } from "./crypto.js";
|
|
9
9
|
import { approvePage, choosePage, consentPage, errorPage, instancePage, scopePage, secretPage, usernamePage, welcomePage } from "./pages.js";
|
|
10
10
|
import { LoginError, PaperclipLogin } from "./paperclip-login.js";
|
|
11
|
+
import { parseAccountPolicy, parseSessionPolicy, readAccountPolicy, readSessionPolicy, surfaceOfTool } from "../access/policy.js";
|
|
12
|
+
import { tools as ALL_TOOLS } from "../tools.js";
|
|
11
13
|
import { VERSION } from "../version.js";
|
|
12
14
|
import { MemoryStore } from "./store.js";
|
|
13
15
|
import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
|
|
@@ -16,6 +18,8 @@ const PENDING_TTL_MS = 10 * 60_000;
|
|
|
16
18
|
const CODE_TTL_MS = 60_000;
|
|
17
19
|
const MAX_PENDING = 5000;
|
|
18
20
|
const MAX_INSTANCE_ATTEMPTS = 5;
|
|
21
|
+
/** Every tool name, for checking a session's tool list. */
|
|
22
|
+
const KNOWN_TOOLS = new Set(ALL_TOOLS.map((t) => t.name));
|
|
19
23
|
/** The plugin offered on the instance step: one fixed package, pinned to this bridge's own version (both are released together). */
|
|
20
24
|
const PLUGIN_PACKAGE = "papercliped-paperclip-plugin";
|
|
21
25
|
/** Its id inside Paperclip (the manifest id), used to see whether it is already installed. */
|
|
@@ -305,7 +309,52 @@ export class OAuthProvider {
|
|
|
305
309
|
async manageConnections(accountId) {
|
|
306
310
|
return (await this.store.listAccountGrants(accountId))
|
|
307
311
|
.filter((g) => !g.revoked && g.clientId !== "paperclip-plugin")
|
|
308
|
-
.map((g) =>
|
|
312
|
+
.map((g) => {
|
|
313
|
+
const p = readSessionPolicy(g.policy, KNOWN_TOOLS);
|
|
314
|
+
return { id: g.id, app: g.clientName, label: p.label, level: levelOf(g.scopes), tools: p.tools, agents: p.agents, createdAt: g.createdAt, lastUsedAt: g.lastUsedAt };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** What the policy engine checks on every call for this grant: the account's switch plus this session's limits. */
|
|
318
|
+
async policyFor(grant) {
|
|
319
|
+
const account = grant.accountId ? await this.store.getAccount(grant.accountId) : undefined;
|
|
320
|
+
return { account: readAccountPolicy(account?.policy), session: readSessionPolicy(grant.policy, KNOWN_TOOLS) };
|
|
321
|
+
}
|
|
322
|
+
async manageGetPolicy(a) {
|
|
323
|
+
return readAccountPolicy((await this.store.getAccount(a.id))?.policy);
|
|
324
|
+
}
|
|
325
|
+
/** Replace the access switch and per-agent overrides. Throws PolicyInputError on anything invalid. Takes effect on the next call. */
|
|
326
|
+
async manageSetPolicy(a, raw) {
|
|
327
|
+
const next = parseAccountPolicy(raw);
|
|
328
|
+
if (!(await this.store.setAccountPolicy(a.id, next)))
|
|
329
|
+
throw new Error("No such account");
|
|
330
|
+
await this.event("updated", { accountId: a.id, username: displayName(a) }, `mode_${next.mode}`);
|
|
331
|
+
return next;
|
|
332
|
+
}
|
|
333
|
+
/** Change one session: its name, tools, agents and (optionally) its access level. False if it isn't this account's live session. */
|
|
334
|
+
async manageSetSession(a, grantId, change) {
|
|
335
|
+
const g = await this.store.getGrant(grantId);
|
|
336
|
+
if (!g || g.revoked || g.accountId !== a.id || g.clientId === "paperclip-plugin")
|
|
337
|
+
return null;
|
|
338
|
+
const before = readSessionPolicy(g.policy, KNOWN_TOOLS);
|
|
339
|
+
const next = parseSessionPolicy({ label: change.label === undefined ? before.label : change.label, tools: change.tools === undefined ? before.tools : change.tools, agents: change.agents === undefined ? before.agents : change.agents }, KNOWN_TOOLS);
|
|
340
|
+
if (!(await this.store.setGrantPolicy(g.id, next)))
|
|
341
|
+
return null;
|
|
342
|
+
if (change.level)
|
|
343
|
+
await this.store.setGrantScopes(g.id, scopesUpTo(change.level));
|
|
344
|
+
await this.event("updated", { accountId: a.id, username: displayName(a) }, "session_limits");
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
/** The tools, as the control room lists them: what each does and which surface it is on. */
|
|
348
|
+
manageTools() {
|
|
349
|
+
return ALL_TOOLS.map((t) => ({ name: t.name, title: t.title, description: t.description.slice(0, 240), access: t.access, surface: surfaceOfTool(t) }));
|
|
350
|
+
}
|
|
351
|
+
/** The newest tool calls through this account's sessions (names and outcomes only; never arguments or results). */
|
|
352
|
+
async manageActivity(a, opts) {
|
|
353
|
+
const grants = (await this.store.listAccountGrants(a.id)).filter((g) => g.clientId !== "paperclip-plugin");
|
|
354
|
+
const ids = opts.session ? grants.filter((g) => g.id === opts.session).map((g) => g.id) : grants.map((g) => g.id);
|
|
355
|
+
const byId = new Map(grants.map((g) => [g.id, readSessionPolicy(g.policy, KNOWN_TOOLS).label ?? g.clientName]));
|
|
356
|
+
const rows = await this.store.recentCallsForGrants(ids, Math.min(Math.max(1, opts.limit), 200));
|
|
357
|
+
return rows.map((r) => ({ at: r.at, tool: r.name, ok: r.ok, blocked: r.errorClass === "policy_blocked", status: r.status, error: r.errorClass, ms: r.totalMs, session: r.grantId, app: (r.grantId && byId.get(r.grantId)) || r.client }));
|
|
309
358
|
}
|
|
310
359
|
/** Change what a connected app may do: Read only or Full control. */
|
|
311
360
|
async manageSetLevel(a, grantId, level) {
|
package/dist/oauth/store.js
CHANGED
|
@@ -239,6 +239,26 @@ export class MemoryStore {
|
|
|
239
239
|
this.save();
|
|
240
240
|
return true;
|
|
241
241
|
}
|
|
242
|
+
async setGrantPolicy(id, policy) {
|
|
243
|
+
const g = this.d.grants[id];
|
|
244
|
+
if (!g || g.revoked)
|
|
245
|
+
return false;
|
|
246
|
+
g.policy = policy ? { ...policy, tools: policy.tools && [...policy.tools], agents: policy.agents && [...policy.agents] } : null;
|
|
247
|
+
this.save();
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
async setAccountPolicy(id, policy) {
|
|
251
|
+
const a = this.accts[id];
|
|
252
|
+
if (!a)
|
|
253
|
+
return false;
|
|
254
|
+
a.policy = { mode: policy.mode, agents: { ...policy.agents } };
|
|
255
|
+
this.save();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
async recentCallsForGrants(grantIds, limit) {
|
|
259
|
+
const ids = new Set(grantIds);
|
|
260
|
+
return this.audit.filter((r) => r.kind === "tool" && r.grantId !== null && ids.has(r.grantId)).slice(-limit).reverse();
|
|
261
|
+
}
|
|
242
262
|
async setAccountPrivacy(id, c) {
|
|
243
263
|
const a = this.accts[id];
|
|
244
264
|
if (!a)
|
package/dist/public-api/types.js
CHANGED
|
@@ -11,4 +11,4 @@ export const PUBLIC_WINDOWS = ["1h", "24h", "7d"];
|
|
|
11
11
|
/** Why a sign-in or connect flow failed. Anything the bridge records outside this list is counted as `other`. */
|
|
12
12
|
export const AUTH_FAILURE_REASONS = ["bad_credentials", "rate_limited", "denied", "expired", "unreachable", "invalid_instance", "too_many_attempts", "other"];
|
|
13
13
|
/** Error classes of tool calls and protocol requests. Anything else is counted as `other`. */
|
|
14
|
-
export const ERROR_CLASSES = ["invalid_input", "insufficient_scope", "read_only", "upstream_4xx", "upstream_5xx", "upstream_unreachable", "rate_limited", "unauthorized", "internal", "other"];
|
|
14
|
+
export const ERROR_CLASSES = ["invalid_input", "insufficient_scope", "read_only", "upstream_4xx", "upstream_5xx", "upstream_unreachable", "rate_limited", "unauthorized", "policy_blocked", "internal", "other"];
|
package/dist/server.js
CHANGED
|
@@ -74,11 +74,11 @@ export function createHttpServer(config, http, opts = {}) {
|
|
|
74
74
|
catch {
|
|
75
75
|
return null; // unreadable credential, expired connection, or instance no longer allowed: an invalid token (the user reconnects)
|
|
76
76
|
}
|
|
77
|
-
return { kind: "oauth", grantId: grant.id, client: grant.clientName, userId: grant.userId, username: grant.username ?? undefined, instance, scopes: grant.scopes, paperclip };
|
|
77
|
+
return { kind: "oauth", grantId: grant.id, client: grant.clientName, userId: grant.userId, username: grant.username ?? undefined, instance, scopes: grant.scopes, paperclip, policy: await oauth.policyFor(grant) };
|
|
78
78
|
}
|
|
79
79
|
const execFor = (p) => p.kind === "static"
|
|
80
80
|
? { client: baseClient, exec: { actor: { id: "static-token" }, audit } }
|
|
81
|
-
: { client: p.paperclip, exec: { scopes: p.scopes, actor: { id: p.grantId, client: p.client, userId: p.userId, instance: p.instance, username: p.username }, audit } };
|
|
81
|
+
: { client: p.paperclip, exec: { scopes: p.scopes, policy: p.policy, actor: { id: p.grantId, client: p.client, userId: p.userId, instance: p.instance, username: p.username }, audit } };
|
|
82
82
|
const unauthorized = (res, invalid) => {
|
|
83
83
|
const challenge = oauth ? oauth.challengeHeader(invalid ? "invalid_token" : undefined) : "Bearer";
|
|
84
84
|
json(res, 401, { error: invalid ? "Invalid or expired token" : "Missing or invalid bearer token" }, { "WWW-Authenticate": challenge });
|
package/dist/site/markdown.js
CHANGED
|
@@ -55,21 +55,42 @@ export function renderMarkdown(md) {
|
|
|
55
55
|
out.push(`<div class="tw"><table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
|
|
56
56
|
continue;
|
|
57
57
|
}
|
|
58
|
+
if (/^>/.test(line)) {
|
|
59
|
+
const buf = [];
|
|
60
|
+
while (i < lines.length && /^>/.test(lines[i]))
|
|
61
|
+
buf.push(lines[i++].replace(/^>\s?/, ""));
|
|
62
|
+
out.push(`<blockquote>${renderMarkdown(buf.join("\n"))}</blockquote>`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
58
65
|
const ul = /^[-*]\s+/.test(line), ol = /^\d+\.\s+/.test(line);
|
|
59
66
|
if (ul || ol) {
|
|
60
67
|
const items = [];
|
|
61
68
|
const re = ul ? /^[-*]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
|
|
62
69
|
while (i < lines.length && re.test(lines[i])) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
const body = [re.exec(lines[i++])[1]];
|
|
71
|
+
// An item continues on indented lines, including fenced code and blank lines followed by more indented content.
|
|
72
|
+
while (i < lines.length && !re.test(lines[i])) {
|
|
73
|
+
if (/^\s+\S/.test(lines[i]))
|
|
74
|
+
body.push(lines[i++]);
|
|
75
|
+
else if (!lines[i].trim() && /^\s+\S/.test(lines[i + 1] ?? "") && !re.test(lines[i + 1] ?? ""))
|
|
76
|
+
body.push(lines[i++]);
|
|
77
|
+
else
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const rich = body.some((l) => /^\s*```/.test(l) || !l.trim());
|
|
81
|
+
if (!rich)
|
|
82
|
+
items.push(`<li>${inline(body.map((l) => l.trim()).join(" "))}</li>`);
|
|
83
|
+
else {
|
|
84
|
+
const rest = body.slice(1);
|
|
85
|
+
const pad = Math.min(...rest.filter((l) => l.trim()).map((l) => /^\s*/.exec(l)[0].length));
|
|
86
|
+
items.push(`<li>${renderMarkdown([body[0], ...rest.map((l) => l.slice(Math.min(pad, /^\s*/.exec(l)[0].length)))].join("\n"))}</li>`);
|
|
87
|
+
}
|
|
67
88
|
}
|
|
68
89
|
out.push(ul ? `<ul>${items.join("")}</ul>` : `<ol>${items.join("")}</ol>`);
|
|
69
90
|
continue;
|
|
70
91
|
}
|
|
71
92
|
const para = [];
|
|
72
|
-
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|```|[-*]\s|\d+\.\s)/.test(lines[i]) && !/^\|.*\|\s*$/.test(lines[i]))
|
|
93
|
+
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|```|[-*]\s|\d+\.\s|>)/.test(lines[i]) && !/^\|.*\|\s*$/.test(lines[i]))
|
|
73
94
|
para.push(lines[i++].trim());
|
|
74
95
|
out.push(`<p>${inline(para.join(" "))}</p>`);
|
|
75
96
|
}
|