papercliped 0.0.0-stage → 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 -0
- package/.claude-plugin/plugin.json +17 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/commands/paperclip-report.md +5 -0
- package/commands/paperclip-status.md +4 -0
- package/commands/paperclip-sync.md +5 -0
- package/dist/accounts/alias.js +28 -0
- package/dist/accounts/secret.js +65 -0
- package/dist/accounts/types.js +2 -0
- package/dist/accounts/username.js +38 -0
- package/dist/cli.js +87 -0
- package/dist/client.js +83 -0
- package/dist/config.js +116 -0
- package/dist/errors.js +3 -0
- package/dist/execute.js +76 -0
- package/dist/http.js +139 -0
- package/dist/manage/routes.js +154 -0
- package/dist/mcp.js +36 -0
- package/dist/migrate.js +75 -0
- package/dist/net/safe-fetch.js +123 -0
- package/dist/oauth/crypto.js +57 -0
- package/dist/oauth/pages.js +191 -0
- package/dist/oauth/paperclip-login.js +97 -0
- package/dist/oauth/pg-store.js +431 -0
- package/dist/oauth/provider.js +1236 -0
- package/dist/oauth/scopes.js +27 -0
- package/dist/oauth/store.js +502 -0
- package/dist/openapi-cli.js +3 -0
- package/dist/openapi.js +58 -0
- 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/reports.js +125 -0
- package/dist/server.js +161 -0
- package/dist/site/favicon.js +4 -0
- package/dist/site/landing.js +169 -0
- package/dist/site/markdown.js +77 -0
- package/dist/site/site.js +132 -0
- package/dist/stdio.js +10 -0
- package/dist/store-factory.js +8 -0
- package/dist/telemetry/recorder.js +197 -0
- package/dist/telemetry/timing.js +29 -0
- package/dist/telemetry/types.js +5 -0
- package/dist/tools.js +458 -0
- package/dist/version.js +3 -0
- 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 -0
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -0
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +123 -0
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -0
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -0
- package/docs/least-privilege.sql +23 -0
- package/docs/legal/PRIVACY.md +37 -0
- package/docs/legal/TERMS.md +11 -0
- package/docs/oauth.md +61 -0
- package/docs/supabase-schema.sql +288 -0
- package/migrations/001_init.sql +84 -0
- package/migrations/002_audit.sql +41 -0
- package/migrations/003_accounts.sql +66 -0
- package/migrations/004_privacy_panel.sql +57 -0
- package/migrations/005_beta.sql +2 -0
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +68 -4
- package/site/docs/anonymous-mode.md +18 -0
- package/site/docs/chatgpt.md +18 -0
- 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 +47 -0
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +19 -0
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +32 -0
- package/site/docs/permissions.md +20 -0
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +25 -0
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +30 -0
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +15 -0
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +51 -0
- package/site/terms.md +13 -0
- package/skills/paperclip/SKILL.md +23 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "papercliped",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "KpnWorld"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "papercliped",
|
|
9
|
+
"source": "./",
|
|
10
|
+
"description": "Run your Paperclip from Claude: control agents, sync with them and get reports, at the access level you choose."
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"description": "Papercliped: run your Paperclip from Claude Code."
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "papercliped",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Run your Paperclip from Claude: control agents, sync with them and get reports, at the access level you choose.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "KpnWorld"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"paperclip",
|
|
11
|
+
"agents",
|
|
12
|
+
"orchestration",
|
|
13
|
+
"mcp"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://papercliped.co",
|
|
16
|
+
"repository": "https://github.com/OpenSourcx/papercliped"
|
|
17
|
+
}
|
package/.mcp.json
ADDED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## v2.1.0 — two access levels, manage in Paperclip, new website
|
|
6
|
+
|
|
7
|
+
- **Dependencies up to date:** Zod 4 (tool schemas and the ChatGPT Actions OpenAPI now come from Zod's built-in JSON Schema; the published contract is unchanged), the latest MCP SDK, undici 8, TypeScript 7, Vitest 5, React 19 and esbuild 0.28 in the plugin, and GitHub Actions v7. No known vulnerabilities in either package. Dependabot now also watches the website and groups minor and patch updates into one pull request per package.
|
|
8
|
+
|
|
9
|
+
- **Install in one step from Claude Code and Codex.** Both plugin marketplaces live in this repository: `/plugin marketplace add OpenSourcx/papercliped` in Claude Code, `codex plugin marketplace add OpenSourcx/papercliped` in Codex. The plugin now connects to the hosted server (`https://mcp.papercliped.co/mcp`) instead of a local build that wasn't in the repository; people running Paperclip on their own machine use `npx papercliped@latest` (see the README).
|
|
10
|
+
- **A README that gets you running:** what Papercliped is and copy-paste setup for Claude, Claude Code, Codex, ChatGPT, any MCP app and local Paperclips, without visiting the docs.
|
|
11
|
+
- **Every push is a release.** CI publishes both npm packages, tags the commit and writes the GitHub release from the changelog and the commit descriptions. `scripts/version.mjs` bumps Major.Minor.Patch in every file, and a release skill for Claude and Codex (`.claude/skills/release`, `.agents/skills/release`) explains when to use which, and how to adopt the same flow in other repositories.
|
|
12
|
+
- **Ready for contributors:** `SECURITY.md` (private reporting, response times), a contributor guide with the path from first PR to maintainer, `CLAUDE.md`/`AGENTS.md` for AI coding agents, and a complete Discord setup in `docs/COMMUNITY.md`.
|
|
13
|
+
|
|
14
|
+
- **New website, phase 1 (foundation):** `web/`, a static React + Tailwind site with self-hosted fonts (Bricolage Grotesque, Inter, JetBrains Mono), the mascot as an animated component (eyes follow the pointer, blinks; still with reduced motion), a component kit and a `/kit` showcase page. **Seasonal themes:** the default `clip` theme plus two palettes per season, each with light and dark, rotating on a schedule that is the same for everyone on a given day; visitors can pick a theme and light/dark/system mode. Every theme passes WCAG AA contrast (tested), every route is checked in headless Chromium under the strict CSP, and nothing is loaded from third parties. Not yet served by the bridge (phase 4).
|
|
15
|
+
- **New website, phase 2 (pages):** a full landing page (live user count, an interactive how-it-works stepper, a permission playground generated from the real tool list, the two plugins, security highlights, FAQ), a **changelog page** built from this file with New/Improved/Fixed filters and an Atom feed (`/changelog.xml`), a **community page** (a suggest-a-feature form that pre-fills a GitHub issue, bug reports, contributing; social links only appear once they exist), a **brand page** with mascot and wordmark downloads and usage rules, and privacy and terms rendered from the same Markdown as the bridge. Playful but accessible interactions (lifting buttons, tilting cards, a mascot that hops), checked for sideways scrolling on a 375px phone. Also: `CODE_OF_CONDUCT.md`, issue and pull request templates, `docs/COMMUNITY.md` (forum and Discord plan), sitemap, robots.txt and a social preview image.
|
|
16
|
+
|
|
17
|
+
- **Public stats API** (no sign-in): `GET /api/public/v1/info`, `status`, `stats`, `series` and `errors` (plus `openapi.json`) publish aggregate users, live connections, sign-in success and failure reasons, request success rate, latency percentiles, error mix and load. Aggregate only: no usernames, aliases, ids, Paperclip addresses, app names or IPs, and no per-user breakdown (tested against memory and Postgres). GET-only, CORS-open without credentials, cached 30 s, 60 requests per minute per address. Types in `src/public-api/types.ts`. New docs page: **Public API and status**.
|
|
18
|
+
- **Status page** on the new website (`/status`): live status badge, people, connections, request and sign-in success, latency percentiles, and charts for requests and errors, response time and sign-ins, plus error-mix, sign-in-failure and top-tool bars. Every chart has a text summary and a table view and never relies on colour alone; it refreshes every 30 seconds.
|
|
19
|
+
- **Removed the operator panel from this repository** (`papercliped-panel`, `src/panel/`, the demo, `PANEL_*` settings, `docs/panel-role.sql`). The operator dashboard now lives in a separate, access-controlled service. Nothing changed in the database: the telemetry tables and the read-only `panel_*` views are still written and kept for it. `npm run build` now cleans `dist/` first.
|
|
20
|
+
|
|
21
|
+
- **New website, phase 3 (docs):** a docs site at `/docs` with a three-column layout (collapsible sections with counts, the article with breadcrumb, last-updated date, copy link and edit-on-GitHub, and an "On this page" outline that follows your scroll), previous/next links, copy buttons on code, and **Ctrl/Cmd-K search** built at compile time (no outside service). The **GitHub star count** shows in the header: the bridge fetches it from GitHub and caches it for an hour (`GET /api/public/v1/repo`), so browsers never call GitHub directly.
|
|
22
|
+
- **Much more documentation:** new pages What is Papercliped? (with a glossary), Run it yourself, Tool reference (generated from the code), Manage API, Environment variables, Command line, Limits and errors, and FAQ. Docs now have 20 pages in five sections; the bridge serves the same pages until the new site goes live.
|
|
23
|
+
|
|
24
|
+
- **The bridge serves the new website** (from `web/dist`, built into the Docker image) with **host routing** (`PUBLIC_HOSTS`): `papercliped.co` for the site, `docs.papercliped.co/topics` for the docs, `mcp.`/`api.papercliped.co` for MCP, OAuth and the APIs, `forum.` redirects to the forum, `www` redirects to the apex. Old `/docs/...` links redirect to the docs host. Every host still answers the protocol and API paths. Unknown pages return a real 404. Without the built site (e.g. the npm package), the bridge serves its Markdown pages as before. Setup and the issuer decision: `docs/DOMAINS.md`.
|
|
25
|
+
- **Sign-in, consent and manage pages restyled** with the website's fonts (Bricolage Grotesque, Inter, JetBrains Mono) and colours, including dark mode. Still plain server-rendered HTML.
|
|
26
|
+
- **New tool `papercliped_service_status`** (read only): the AI can check whether the hosted service is working, from the public status API. The catalogue is now 30 tools.
|
|
27
|
+
- **Paperclip plugin:** a "Papercliped service" card shows the bridge's public status (no link needed).
|
|
28
|
+
|
|
29
|
+
- **AI apps now connect at `https://mcp.papercliped.co/mcp`** (the OAuth issuer moves to `mcp.papercliped.co`; the website, docs and `/manage` stay on `papercliped.co`). Docs show the connector address through a new `{{MCP}}` placeholder, so self-hosted bridges show their own. Anyone connected before must remove the connector and add the new address.
|
|
30
|
+
|
|
31
|
+
- **Website redesign:** a bolder homepage (a centred headline, pill buttons, a gradient pill hero with the mascot in the middle; click it for a wave), a shorter header (Product, Docs, Resources, Community, search, GitHub stars, Get started). The docs get their own header (links left, "papercliped Docs" centred, search and stars right), a sidebar grouped into Learn, Reference and Project with icons, counts and collapsible sections, and a page header with breadcrumb, tag and a Copy link menu (view the Markdown source, suggest an edit). The changelog moves into the docs layout as expandable release cards.
|
|
32
|
+
- **Light and dark now follow your device automatically.** The manual System/Light/Dark switch is gone (an old saved choice is cleared); the seasonal palettes still rotate on their own.
|
|
33
|
+
|
|
34
|
+
- **Two access levels: Read only and Full control.** Full control is now every tool (pause, wake, terminate, issues, goals, comments, approvals, budgets and raw API writes), so you can run your Paperclip from anywhere. The separate "Admin" level is gone; existing admin grants and apps that still ask for it get Full control (migration `006_two_levels.sql` tidies stored grants). The sign-in page, the manage page, the Paperclip plugin, the docs and the website playground all show the two levels.
|
|
35
|
+
|
|
36
|
+
- **Managing your account moved into Paperclip.** The Papercliped plugin is now the connection manager: link it with your username and secret key (checked once, not stored), then change levels, disconnect apps, switch anonymity, see and remove linked Paperclips, make a new secret key (this Paperclip stays linked), disconnect your Paperclip or delete your account. The `/manage` web page, its cookie sign-in, one-time link codes and the beta opt-in are gone (`/manage` now points to the plugin docs); the manage API accepts plugin tokens only.
|
|
37
|
+
|
|
38
|
+
- **Docs live at `docs.papercliped.co/<page>`** (for example `docs.papercliped.co/permissions`), with a real docs landing at `docs.papercliped.co/`: search, the connector address, four start-here cards and every topic. Old `/topics/...` and `papercliped.co/docs/...` links redirect.
|
|
39
|
+
|
|
40
|
+
- **A cleaner homepage hero:** instead of decorative art, a live demo of the product. An AI app on the left steers a Paperclip on the right (pause an agent, weekly report, approve a request), using the real tool names, with tabs to replay each one. The footer gets X, GitHub and Discord icons (X and Discord point to the community page until their addresses are set).
|
|
41
|
+
|
|
42
|
+
- **The Papercliped page in Paperclip, rebuilt** with Paperclip's own components (status badges, metric cards, tables, toasts) so it looks native: a service panel, an account summary, and tabs for Connected apps (level dropdown, two-step disconnect), Privacy, Linked Paperclips and Account (new secret key shown once with copy, disconnect Paperclip, delete account). Covered by UI tests against the real plugin actions.
|
|
43
|
+
|
|
44
|
+
## v2.0.0 — stable open-source build
|
|
45
|
+
|
|
46
|
+
Papercliped v2 is the free, open-source foundation: the bridge, the connector for Claude and any MCP app, and the Paperclip plugin. Paid features (managed subdomains, pricing, legal) are planned for v3 (`docs/V3-SUBDOMAINS.md`).
|
|
47
|
+
|
|
48
|
+
- **New home: `https://papercliped.co`.** Every reference to the old domain is gone. The Paperclip plugin's default bridge URL is now `https://papercliped.co`. Operators: set `BRIDGE_PUBLIC_URL=https://papercliped.co`; apps connected under the old address must connect again (the OAuth issuer changed). Cloudflare + Render setup: `docs/LAUNCH.md` §3.
|
|
49
|
+
- **Two plugins, documented side by side:** Papercliped for AI apps (Claude, ChatGPT, any MCP client) and the Papercliped plugin for Paperclip. New docs page: **Any AI app (MCP)**.
|
|
50
|
+
- **Automated npm publishing** through GitHub Actions with npm trusted publishing (OIDC, no token): one tag publishes `papercliped` and `papercliped-paperclip-plugin` and creates the GitHub release (`docs/RELEASING.md`). CI now also packs both packages and installs them as a user would, and a test keeps every version string in step.
|
|
51
|
+
- **Foundation:** CodeQL, Dependabot, dependency audit and read-only permissions in CI; `CONTRIBUTING.md`; `docs/SITEMAP.md` (every page and endpoint); new docs page **Set up your Paperclip** (public address with or without a domain); the security doc covers plugin links.
|
|
52
|
+
- Fixed: the Claude Code install command in the README (`/plugin install papercliped@papercliped`).
|
|
53
|
+
|
|
54
|
+
Not tested live: the Paperclip plugin has never run inside a real Paperclip, and the release workflow has not run yet (it can only run on GitHub).
|
|
55
|
+
|
|
56
|
+
## v1.2.0-beta.1 — Paperclip plugin
|
|
57
|
+
|
|
58
|
+
- **Paperclip plugin** (`plugin/`, npm `papercliped-paperclip-plugin`, a separate package built on `@paperclipai/plugin-sdk`): a **Papercliped** sidebar entry and page inside Paperclip to link your account, list connected apps, switch them between Read only and Full control (beta), disconnect them, toggle anonymity, and unlink. The bridge URL is configurable (https only).
|
|
59
|
+
- **Plugin link API**: `POST /api/manage/plugin-link` (beta, browser session) makes a one-time `pcl_…` code (10 minutes, single use); `POST /api/manage/plugin-link/exchange` trades it for a long-lived `pcb_pl_…` token (rate-limited by address; wrong, used and expired codes answer identically); `GET /api/manage/plugin-links`; `DELETE /api/manage/plugin-links/:id`. The `/manage` page gains a **Link Paperclip plugin** section.
|
|
60
|
+
- **Plugin tokens** are accepted as `Authorization: Bearer` on the manage API (no CSRF header needed). They hold no scopes, so they cannot call any Paperclip tool or the MCP endpoint, and they cannot mint codes, change the beta, sign out, or reach any action that needs the secret key (rotate key, disconnect Paperclip, delete account); those return 403 and need the browser. A token can remove only itself.
|
|
61
|
+
- **Making a new secret key now also revokes every plugin link.**
|
|
62
|
+
- New docs page: Paperclip plugin.
|
|
63
|
+
- New docs page: **Set up your Paperclip**, a step-by-step guide to getting a public https address (Cloudflare Tunnel with your own domain, or Tailscale Funnel, ngrok or a Cloudflare quick tunnel without one).
|
|
64
|
+
|
|
65
|
+
Not tested: the plugin has never run inside a live Paperclip. It is covered by unit tests against a fake bridge and the SDK's test harness only.
|
|
66
|
+
|
|
67
|
+
## v1.1.0-beta.2 — connection manager, public site
|
|
68
|
+
|
|
69
|
+
- **Beta connection manager** at `/manage` and a management API (`/api/manage/*`): sign in with username + secret key, see every connected app, switch it between Read only and Full control (beta) with immediate effect, disconnect it, toggle anonymity, make a new secret key, disconnect your Paperclip, delete your account. Sensitive actions re-ask for the secret key; Admin is never grantable from here. Opt in with **Join the beta** on the permission screen (migration `005_beta.sql`, applied automatically).
|
|
70
|
+
- **Full control (beta)** is offered to everyone on the permission screen; Admin only when the app asks for it.
|
|
71
|
+
- **Public site** served by the bridge: an interactive landing page ("Papercliped, not Paperclipped."), live user count, docs (getting started, create your account, permissions, anonymous mode, manage connections, security, ChatGPT, troubleshooting), privacy and terms. A new smiling-paperclip favicon on every page.
|
|
72
|
+
- Windows-friendly test suite; `npm publish` only builds.
|
|
73
|
+
- Set `SITE_CONTACT` (an email or support URL) so the privacy and terms pages show a real contact.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## v1.0.0-beta.1 — first public beta
|
|
77
|
+
|
|
78
|
+
Papercliped connects Claude and ChatGPT to your own Paperclip: control agents, sync state, pull reports.
|
|
79
|
+
|
|
80
|
+
- **MCP server** (stdio + Streamable HTTP) and **ChatGPT Actions** (generated OpenAPI) over Paperclip's REST API.
|
|
81
|
+
- **Hosted multi-tenant bridge** with its own OAuth 2.1 server (DCR, PKCE, rotating refresh tokens, scopes read/control/admin).
|
|
82
|
+
- **Papercliped accounts**: choose a unique username (6–32 chars, at least one number or `. # _`) and get a secret key. Sign in with the key, or reconnect by approving in your Paperclip — you keep your account and a live connection.
|
|
83
|
+
- **Anonymous mode**: show as an alias (e.g. `Ann02`) in logs and the operator panel.
|
|
84
|
+
- **SSRF-hardened egress** for user-supplied Paperclip URLs; sealed (AES-256-GCM) stored credentials with key rotation.
|
|
85
|
+
- **Operator panel — a separate program** (`papercliped-panel`) reading a read-only database role: latency, system faults, success rate, user count, and the community log (`09:25:30 og.kpnwrld - joined cliped`).
|
|
86
|
+
- Free-tier friendly: keep-alive worker, database heartbeat, auto-migrate; one-paste Supabase schema (`docs/supabase-schema.sql`).
|
|
87
|
+
|
|
88
|
+
Known limits: not yet validated against live Render / Supabase / claude.ai / ChatGPT; code has not had an independent security audit. See `docs/SECURITY.md`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KpnWorld
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Papercliped
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Run your [Paperclip](https://github.com/paperclipai/paperclip) from any AI app.** Papercliped connects Claude, ChatGPT, Codex and any MCP app to your Paperclip, the open-source orchestrator for companies of AI agents. Check on agents, pause or wake them, assign issues, decide approvals and get status, cost and activity reports, without opening the dashboard.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/OpenSourcx/papercliped/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/papercliped)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
Free and open source. Website: [papercliped.co](https://papercliped.co) · Docs: [docs.papercliped.co](https://docs.papercliped.co)
|
|
10
|
+
|
|
11
|
+
> Papercliped is an independent community project, not made by the Paperclip team.
|
|
12
|
+
|
|
13
|
+
## Add it to your AI app
|
|
14
|
+
|
|
15
|
+
You need a Paperclip with a public `https` address ([no domain? see this](https://docs.papercliped.co/connect-your-paperclip)). 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
|
+
|
|
17
|
+
The connector address for every app is:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
https://mcp.papercliped.co/mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Claude (web, desktop and mobile)
|
|
24
|
+
|
|
25
|
+
**Settings → Connectors → Add custom connector**, paste `https://mcp.papercliped.co/mcp`, then **Connect**.
|
|
26
|
+
|
|
27
|
+
### Claude Code
|
|
28
|
+
|
|
29
|
+
Install the plugin (adds the tools, a Paperclip skill and `/paperclip-status`, `/paperclip-sync`, `/paperclip-report`):
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
/plugin marketplace add OpenSourcx/papercliped
|
|
33
|
+
/plugin install papercliped@papercliped
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or just the MCP server:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
claude mcp add --transport http paperclip https://mcp.papercliped.co/mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then run `/mcp` to sign in.
|
|
43
|
+
|
|
44
|
+
### Codex
|
|
45
|
+
|
|
46
|
+
Install the plugin:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
codex plugin marketplace add OpenSourcx/papercliped
|
|
50
|
+
codex plugin add papercliped@papercliped
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or just the MCP server (Codex starts the sign-in for you):
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
codex mcp add paperclip --url https://mcp.papercliped.co/mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### ChatGPT
|
|
60
|
+
|
|
61
|
+
Turn on **Developer mode** (Settings → Apps → Advanced settings), choose **Create app**, paste `https://mcp.papercliped.co/mcp` and pick OAuth. Menu names change often; the [ChatGPT guide](https://docs.papercliped.co/chatgpt) has the current steps and the Custom GPT (Actions) option.
|
|
62
|
+
|
|
63
|
+
### Any other MCP app
|
|
64
|
+
|
|
65
|
+
Add a remote MCP server (Streamable HTTP, OAuth) at `https://mcp.papercliped.co/mcp`. See [Any AI app](https://docs.papercliped.co/other-ai-apps).
|
|
66
|
+
|
|
67
|
+
### Paperclip on your own machine (no public address)
|
|
68
|
+
|
|
69
|
+
Run Papercliped locally instead. It talks straight to your Paperclip; nothing goes through papercliped.co. Use a Paperclip **board** token (agent keys can't pause or approve):
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
# Claude Code
|
|
73
|
+
claude mcp add paperclip --env PAPERCLIP_API_URL=http://localhost:3100 --env PAPERCLIP_API_KEY=<board token> -- npx -y papercliped@latest
|
|
74
|
+
|
|
75
|
+
# Codex
|
|
76
|
+
codex mcp add paperclip --env PAPERCLIP_API_URL=http://localhost:3100 --env PAPERCLIP_API_KEY=<board token> -- npx -y papercliped@latest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Add `--env PAPERCLIP_READ_ONLY=1` for reports and sync only.
|
|
80
|
+
|
|
81
|
+
## What you can ask
|
|
82
|
+
|
|
83
|
+
- "What are my agents working on?" · "Which agents are over budget?"
|
|
84
|
+
- "Pause the Web Engineer." · "Wake the CEO and assign it the onboarding issue."
|
|
85
|
+
- "Approve the hire request." · "Weekly cost report, please."
|
|
86
|
+
|
|
87
|
+
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
|
+
|
|
89
|
+
## Manage your connections in Paperclip
|
|
90
|
+
|
|
91
|
+
Install the **Papercliped plugin for Paperclip** (npm `papercliped-paperclip-plugin`, from **Settings → Plugins** in Paperclip). It adds a Papercliped page where you see every connected app, switch its level, disconnect it, go anonymous, and look after your account. [Plugin guide](https://docs.papercliped.co/paperclip-plugin).
|
|
92
|
+
|
|
93
|
+
## Security and privacy
|
|
94
|
+
|
|
95
|
+
- You approve every connection inside your own Paperclip; no passwords pass through Papercliped.
|
|
96
|
+
- Levels are enforced by Papercliped on every call, and every call is audit-logged.
|
|
97
|
+
- Paperclip keys are encrypted at rest; tokens and secret keys are stored only as one-way hashes.
|
|
98
|
+
- Only public `https` Paperclip addresses are reachable from the hosted service.
|
|
99
|
+
- No trackers or third-party scripts on the website.
|
|
100
|
+
|
|
101
|
+
Threat model: [docs/SECURITY.md](docs/SECURITY.md). Report a vulnerability: [SECURITY.md](SECURITY.md). Privacy: [papercliped.co/privacy](https://papercliped.co/privacy).
|
|
102
|
+
|
|
103
|
+
## Run it yourself
|
|
104
|
+
|
|
105
|
+
The hosted service runs exactly this code. To host your own (for a team, or to keep everything on your infrastructure), see [Run it yourself](https://docs.papercliped.co/self-hosting), [docs/oauth.md](docs/oauth.md) and the [launch guide](docs/LAUNCH.md) (Render + Supabase, free tiers).
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
git clone https://github.com/OpenSourcx/papercliped && cd papercliped
|
|
109
|
+
npm install && npm test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Community and contributing
|
|
113
|
+
|
|
114
|
+
- Questions and ideas: [GitHub Discussions](https://github.com/OpenSourcx/papercliped/discussions) and Discord ([papercliped.co/community](https://papercliped.co/community))
|
|
115
|
+
- Bugs and feature requests: [issues](https://github.com/OpenSourcx/papercliped/issues/new/choose)
|
|
116
|
+
- Contributing (setup, checks, the path from first PR to maintainer): [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
117
|
+
- Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
118
|
+
- Every push to `main` is a release: [changelog](CHANGELOG.md) · [how releases work](docs/RELEASING.md)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Produce a Paperclip report — status, costs, performance or activity
|
|
3
|
+
argument-hint: "[status|costs|performance|activity]"
|
|
4
|
+
---
|
|
5
|
+
Produce the report named in "$ARGUMENTS" (default `status`): `status` → `paperclip_report_status`, `costs` → `paperclip_report_costs`, `performance` → `paperclip_report_agent_performance`, `activity` → `paperclip_report_activity` (since the start of today, UTC). Present the markdown, then add 2–3 sentences of analysis: what looks off and what you would do about it.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show a Paperclip status report (agents, tasks, spend, approvals)
|
|
3
|
+
---
|
|
4
|
+
Call `paperclip_report_status` and present the result. Then list any agents in error or paused and any pending approvals, and suggest next steps — but do not take any action without asking.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Sync with Paperclip agents — current state plus what changed since last sync
|
|
3
|
+
argument-hint: "[cursor from previous sync]"
|
|
4
|
+
---
|
|
5
|
+
Call `paperclip_sync_snapshot`. If a cursor was given ($ARGUMENTS), also call `paperclip_sync_changes` with it. Summarise what each agent is doing, what changed, and end with the new cursor so the next sync can continue from it.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Anonymous display names: a short first name + two digits ("Ann02", "Kai47").
|
|
5
|
+
*
|
|
6
|
+
* They are deliberately SHORTER than the minimum username length (6), so an alias can never be a valid username and the two
|
|
7
|
+
* can't be confused in a log line. Names are 2–3 letters; with ~120 names × 100 numbers there are ~12,000 aliases.
|
|
8
|
+
*/
|
|
9
|
+
const NAMES = [
|
|
10
|
+
"Abe", "Ace", "Ada", "Al", "Ali", "Amy", "Ann", "Art", "Ava", "Bea", "Ben", "Bo", "Bud", "Cal", "Cat", "Cy", "Dan", "Dee", "Di", "Don",
|
|
11
|
+
"Eda", "Ed", "Eli", "Eva", "Eve", "Fay", "Flo", "Fox", "Gia", "Gil", "Gus", "Hal", "Hui", "Ian", "Ida", "Ira", "Ivy", "Jan", "Jay", "Jo",
|
|
12
|
+
"Joy", "Kai", "Kim", "Kit", "Lea", "Lee", "Leo", "Lin", "Liv", "Lou", "Lu", "Max", "May", "Mei", "Mia", "Moe", "Nat", "Ned", "Nia", "Noa",
|
|
13
|
+
"Oli", "Ola", "Ona", "Ora", "Pam", "Pat", "Pip", "Raj", "Ray", "Rex", "Rio", "Rob", "Roy", "Rue", "Sal", "Sam", "Sia", "Sol", "Sue", "Taj",
|
|
14
|
+
"Tia", "Tim", "Tom", "Tye", "Uma", "Una", "Val", "Van", "Vic", "Vin", "Wes", "Wil", "Xia", "Xan", "Yan", "Yen", "Zac", "Zed", "Zoe", "Zia",
|
|
15
|
+
"Bay", "Cai", "Dev", "Eli", "Gwen".slice(0, 3), "Hoa", "Isa", "Jin", "Kay", "Lex", "Mac", "Nox", "Ozu", "Pru", "Quo", "Rae", "Sky", "Teo", "Uri", "Vee",
|
|
16
|
+
];
|
|
17
|
+
const POOL = [...new Set(NAMES)];
|
|
18
|
+
/** Shape of every alias; usernames (≥6 chars) can never match this. */
|
|
19
|
+
export const ALIAS_RE = /^[A-Z][a-z]{1,2}\d{2}$/;
|
|
20
|
+
export const generateAlias = (rand = (n) => randomInt(n)) => `${POOL[rand(POOL.length)]}${String(rand(100)).padStart(2, "0")}`;
|
|
21
|
+
export const isAlias = (s) => !!s && ALIAS_RE.test(s);
|
|
22
|
+
/** The name that appears in logs, the operator dashboard and audit rows. */
|
|
23
|
+
export const displayName = (a) => (a.anonymous && a.alias ? a.alias : a.username);
|
|
24
|
+
/**
|
|
25
|
+
* A stable label standing in for a tenant's Paperclip hostname when its owner is anonymous ("anon-3f9a1c"). It keeps one tenant's
|
|
26
|
+
* activity grouped (so slow-instance debugging still works) without putting the hostname in logs. Keyed so it can't be reversed by guessing hosts.
|
|
27
|
+
*/
|
|
28
|
+
export const anonInstanceLabel = (key, accountId) => `anon-${createHmac("sha256", key).update(`instance|${accountId}`).digest("hex").slice(0, 6)}`;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* The account "secret key": generated by us (never user-chosen, so no weak or reused passwords), 160 bits, shown once.
|
|
4
|
+
* Looks like pcs_ABCD-EFGH-JKMN-PQRS-TVWX-YZ23-4567-89AB (Crockford base32: no I, L, O, U).
|
|
5
|
+
* Only a salted scrypt hash is stored.
|
|
6
|
+
*/
|
|
7
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
8
|
+
const SECRET_CHARS = 32;
|
|
9
|
+
function base32(buf) {
|
|
10
|
+
let bits = 0;
|
|
11
|
+
let value = 0;
|
|
12
|
+
let out = "";
|
|
13
|
+
for (const byte of buf) {
|
|
14
|
+
value = (value << 8) | byte;
|
|
15
|
+
bits += 8;
|
|
16
|
+
while (bits >= 5) {
|
|
17
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
18
|
+
bits -= 5;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export function generateSecretKey() {
|
|
24
|
+
const raw = base32(randomBytes(20)); // 20 bytes = 160 bits = exactly 32 base32 chars
|
|
25
|
+
return `pcs_${raw.match(/.{4}/g).join("-")}`;
|
|
26
|
+
}
|
|
27
|
+
/** Forgiving about what people type: case, dashes, spaces, the pcs_ prefix, and Crockford look-alikes (O→0, I/L→1). Null if malformed. */
|
|
28
|
+
export function normalizeSecretKey(input) {
|
|
29
|
+
if (typeof input !== "string")
|
|
30
|
+
return null;
|
|
31
|
+
let s = input.trim().toUpperCase().replace(/^PCS[_-]?/, "").replace(/[\s-]/g, "");
|
|
32
|
+
s = s.replace(/O/g, "0").replace(/[IL]/g, "1");
|
|
33
|
+
return s.length === SECRET_CHARS && [...s].every((c) => ALPHABET.includes(c)) ? s : null;
|
|
34
|
+
}
|
|
35
|
+
const N = 16384; // 16 MiB: affordable on a small free instance, and the key has 160 bits of entropy anyway
|
|
36
|
+
const R = 8;
|
|
37
|
+
const P = 1;
|
|
38
|
+
const KEYLEN = 32;
|
|
39
|
+
const scryptAsync = (secret, salt, n = N, r = R, p = P) => new Promise((resolve, reject) => scrypt(secret, salt, KEYLEN, { N: n, r, p, maxmem: 64 * 1024 * 1024 }, (e, k) => (e ? reject(e) : resolve(k))));
|
|
40
|
+
/** Hashes a (normalized) secret key. Runs on the libuv threadpool, so it doesn't block the event loop. */
|
|
41
|
+
export async function hashSecretKey(normalized) {
|
|
42
|
+
const salt = randomBytes(16);
|
|
43
|
+
const hash = await scryptAsync(normalized, salt);
|
|
44
|
+
return `scrypt$${N}$${R}$${P}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
45
|
+
}
|
|
46
|
+
export async function verifySecretKey(input, stored) {
|
|
47
|
+
const normalized = normalizeSecretKey(input);
|
|
48
|
+
const parts = stored.split("$");
|
|
49
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
50
|
+
return false;
|
|
51
|
+
const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
|
|
52
|
+
if (![n, r, p].every(Number.isInteger))
|
|
53
|
+
return false;
|
|
54
|
+
// Always do the work, even for a malformed guess, so timing doesn't reveal which check failed.
|
|
55
|
+
const expected = Buffer.from(parts[5], "base64");
|
|
56
|
+
const got = await scryptAsync(normalized ?? "invalid", Buffer.from(parts[4], "base64"), n, r, p);
|
|
57
|
+
return normalized !== null && got.length === expected.length && timingSafeEqual(got, expected);
|
|
58
|
+
}
|
|
59
|
+
let dummy;
|
|
60
|
+
/** Verify against a throwaway hash when the username doesn't exist, so "unknown user" costs the same as "wrong key". */
|
|
61
|
+
export async function burnVerify(input) {
|
|
62
|
+
dummy ??= hashSecretKey("0".repeat(SECRET_CHARS));
|
|
63
|
+
await verifySecretKey(input, await dummy);
|
|
64
|
+
}
|
|
65
|
+
export const maskSecretKey = (s) => `${s.slice(0, 8)}…`;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Papercliped usernames.
|
|
3
|
+
*
|
|
4
|
+
* Rules (from the product spec):
|
|
5
|
+
* - unique, compared case-insensitively ("OG.kpnwrld" and "og.kpnwrld" are the same name)
|
|
6
|
+
* - at least 6 characters (assumption: the spec says "up to 6" but its own example `og.kpnwrld` is 10 — see USERNAME_MIN)
|
|
7
|
+
* - must contain at least one number or symbol
|
|
8
|
+
* - the only allowed symbols are . # _ (letters and digits otherwise; ASCII only, so no look-alike characters)
|
|
9
|
+
* Extra, to keep names usable in logs: at most 32 characters, must start with a letter or digit, and a few
|
|
10
|
+
* impersonation-prone names are reserved.
|
|
11
|
+
*/
|
|
12
|
+
export const USERNAME_MIN = 6;
|
|
13
|
+
export const USERNAME_MAX = 32;
|
|
14
|
+
export const USERNAME_SYMBOLS = [".", "#", "_"];
|
|
15
|
+
const ALLOWED = /^[A-Za-z0-9._#]+$/;
|
|
16
|
+
const STARTS_OK = /^[A-Za-z0-9]/;
|
|
17
|
+
const HAS_NUMBER_OR_SYMBOL = /[0-9._#]/;
|
|
18
|
+
/** Compared against the letters-only form of a name, so "admin.1" and "root_9" are caught too. */
|
|
19
|
+
const RESERVED = new Set(["admin", "administrator", "root", "system", "support", "staff", "official", "moderator", "papercliped", "paperclip", "cliped", "kpnworld", "anthropic", "claude", "openai", "chatgpt"]);
|
|
20
|
+
export const usernameKey = (username) => username.toLowerCase();
|
|
21
|
+
export function validateUsername(raw) {
|
|
22
|
+
if (typeof raw !== "string")
|
|
23
|
+
return { ok: false, reason: "Choose a username." };
|
|
24
|
+
const u = raw.trim();
|
|
25
|
+
if (u.length < USERNAME_MIN)
|
|
26
|
+
return { ok: false, reason: `Usernames need at least ${USERNAME_MIN} characters.` };
|
|
27
|
+
if (u.length > USERNAME_MAX)
|
|
28
|
+
return { ok: false, reason: `Usernames can be at most ${USERNAME_MAX} characters.` };
|
|
29
|
+
if (!ALLOWED.test(u))
|
|
30
|
+
return { ok: false, reason: "Use only letters, numbers and these symbols: . # _" };
|
|
31
|
+
if (!STARTS_OK.test(u))
|
|
32
|
+
return { ok: false, reason: "Start your username with a letter or number." };
|
|
33
|
+
if (!HAS_NUMBER_OR_SYMBOL.test(u))
|
|
34
|
+
return { ok: false, reason: "Include at least one number or one of these symbols: . # _" };
|
|
35
|
+
if (RESERVED.has(u.toLowerCase().replace(/[^a-z]/g, "")))
|
|
36
|
+
return { ok: false, reason: "That name is reserved. Please pick another." };
|
|
37
|
+
return { ok: true, username: u, key: usernameKey(u) };
|
|
38
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readConfig, readDatabase, readHttpConfig } from "./config.js";
|
|
3
|
+
import { consolidatedSql, migrate } from "./migrate.js";
|
|
4
|
+
import { OAuthProvider } from "./oauth/provider.js";
|
|
5
|
+
import { createStore } from "./store-factory.js";
|
|
6
|
+
const USAGE = `paperclip-bridge-admin <command>
|
|
7
|
+
|
|
8
|
+
schema-sql print ONE paste-able SQL script (all migrations) for the Supabase SQL editor
|
|
9
|
+
migrate apply database migrations (uses DATABASE_MIGRATE_URL if set, else DATABASE_URL; needs a direct/session connection)
|
|
10
|
+
users list accounts: real username, alias (if anonymous), when last signed in (OPERATOR ONLY: this un-masks aliases)
|
|
11
|
+
delete-user <username> delete an account, its stored credential and grants (asks that Paperclip to revoke the key)
|
|
12
|
+
grants list live grants (no secrets are shown)
|
|
13
|
+
revoke <id> revoke one grant and delete its stored credential
|
|
14
|
+
sweep revoke grants idle longer than BRIDGE_IDLE_REVOKE_DAYS
|
|
15
|
+
revoke-all --yes incident response: revoke EVERY grant, delete all stored credentials, ask each tenant's Paperclip to revoke its key
|
|
16
|
+
rotate-keys re-seal stored credentials with the current BRIDGE_SECRET (put the old one in BRIDGE_SECRET_PREVIOUS first)
|
|
17
|
+
`;
|
|
18
|
+
async function main() {
|
|
19
|
+
const [cmd, arg] = process.argv.slice(2);
|
|
20
|
+
if (cmd === "schema-sql") {
|
|
21
|
+
process.stdout.write(consolidatedSql());
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (cmd === "migrate") {
|
|
25
|
+
const url = process.env.DATABASE_MIGRATE_URL?.trim();
|
|
26
|
+
const db = readDatabase(url ? { ...process.env, DATABASE_URL: url } : process.env);
|
|
27
|
+
if (!db)
|
|
28
|
+
throw new Error("DATABASE_URL (or DATABASE_MIGRATE_URL) is not set");
|
|
29
|
+
const applied = await migrate(db);
|
|
30
|
+
console.log(applied.length ? `applied: ${applied.join(", ")}` : "database is up to date");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!["users", "delete-user", "grants", "revoke", "revoke-all", "sweep", "rotate-keys"].includes(cmd ?? "")) {
|
|
34
|
+
console.error(USAGE);
|
|
35
|
+
process.exit(cmd ? 2 : 0);
|
|
36
|
+
}
|
|
37
|
+
const http = readHttpConfig();
|
|
38
|
+
if (!http.oauth)
|
|
39
|
+
throw new Error("BRIDGE_OAUTH=1 and its settings are required for this command");
|
|
40
|
+
const provider = new OAuthProvider({ config: readConfig(), oauth: http.oauth, bridgeToken: http.bridgeToken, store: createStore(http.oauth) });
|
|
41
|
+
try {
|
|
42
|
+
if (cmd === "users") {
|
|
43
|
+
for (const a of await provider.store.listAccounts(1000)) {
|
|
44
|
+
console.log([a.username, a.anonymous ? `anonymous as ${a.alias}` : "public", a.disabled ? "disabled" : "active", a.lastLoginAt ? `last login ${new Date(a.lastLoginAt).toISOString()}` : "never signed in"].join("\t"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (cmd === "delete-user") {
|
|
48
|
+
if (!arg)
|
|
49
|
+
throw new Error("usage: delete-user <username>");
|
|
50
|
+
console.log((await provider.deleteUser(arg)) ? `deleted ${arg}` : `no such user: ${arg}`);
|
|
51
|
+
}
|
|
52
|
+
else if (cmd === "grants") {
|
|
53
|
+
for (const g of await provider.listGrants()) {
|
|
54
|
+
console.log([g.id, g.clientName, g.instanceUrl ?? "-", g.scopes.join(","), `last used ${new Date(g.lastUsedAt).toISOString()}`].join("\t"));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (cmd === "revoke") {
|
|
58
|
+
if (!arg)
|
|
59
|
+
throw new Error("usage: revoke <grant id>");
|
|
60
|
+
await provider.revokeGrant(arg);
|
|
61
|
+
console.log(`revoked ${arg}`);
|
|
62
|
+
}
|
|
63
|
+
else if (cmd === "revoke-all") {
|
|
64
|
+
if (arg !== "--yes")
|
|
65
|
+
throw new Error("this revokes every connection; re-run with --yes");
|
|
66
|
+
const live = await provider.listGrants();
|
|
67
|
+
for (const g of live)
|
|
68
|
+
await provider.revokeGrant(g.id);
|
|
69
|
+
console.log(`revoked ${live.length} grant(s)`);
|
|
70
|
+
}
|
|
71
|
+
else if (cmd === "sweep") {
|
|
72
|
+
console.log(`revoked ${await provider.sweep()} idle grant(s)`);
|
|
73
|
+
}
|
|
74
|
+
else if (cmd === "rotate-keys") {
|
|
75
|
+
console.log(`re-sealed ${await provider.rotateKeys()} credential(s)`);
|
|
76
|
+
}
|
|
77
|
+
// give best-effort upstream revocations a moment to leave
|
|
78
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await provider.store.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
main().catch((e) => {
|
|
85
|
+
console.error(`error: ${e.message}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
});
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ToolInputError } from "./errors.js";
|
|
2
|
+
import { trackUpstream } from "./telemetry/timing.js";
|
|
3
|
+
export class PaperclipApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
method;
|
|
6
|
+
path;
|
|
7
|
+
body;
|
|
8
|
+
constructor(status, method, path, body) {
|
|
9
|
+
const detail = body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
|
10
|
+
? `: ${body.error}`
|
|
11
|
+
: "";
|
|
12
|
+
super(`${method} ${path} failed with ${status}${detail}`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.method = method;
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.body = body;
|
|
17
|
+
this.name = "PaperclipApiError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class PaperclipClient {
|
|
21
|
+
config;
|
|
22
|
+
fetchImpl;
|
|
23
|
+
constructor(config, fetchImpl = fetch) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.fetchImpl = fetchImpl;
|
|
26
|
+
}
|
|
27
|
+
resolveCompanyId(explicit) {
|
|
28
|
+
const id = explicit?.trim() || this.config.companyId;
|
|
29
|
+
if (!id) {
|
|
30
|
+
throw new ToolInputError("companyId is required: pass it, or set PAPERCLIP_COMPANY_ID (use paperclip_list_companies to find one)");
|
|
31
|
+
}
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
async request(method, path, opts = {}) {
|
|
35
|
+
if (!path.startsWith("/"))
|
|
36
|
+
throw new ToolInputError(`API path must start with "/": ${path}`);
|
|
37
|
+
if (path.includes(".."))
|
|
38
|
+
throw new ToolInputError("API path must not contain '..'");
|
|
39
|
+
const url = new URL(`${this.config.apiUrl}${path}`);
|
|
40
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
41
|
+
if (v !== undefined && v !== null && v !== "")
|
|
42
|
+
url.searchParams.set(k, String(v));
|
|
43
|
+
}
|
|
44
|
+
const headers = { Accept: "application/json" };
|
|
45
|
+
if (this.config.apiKey)
|
|
46
|
+
headers.Authorization = `Bearer ${this.config.apiKey}`;
|
|
47
|
+
if (opts.body !== undefined)
|
|
48
|
+
headers["Content-Type"] = "application/json";
|
|
49
|
+
let res;
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
// Time headers AND body transfer as "upstream": that is the wait the caller experiences.
|
|
53
|
+
({ res, text } = await trackUpstream(async () => {
|
|
54
|
+
const r = await this.fetchImpl(url, {
|
|
55
|
+
method,
|
|
56
|
+
headers,
|
|
57
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
58
|
+
signal: AbortSignal.timeout(this.config.timeoutMs),
|
|
59
|
+
});
|
|
60
|
+
return { res: r, text: await r.text() };
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const why = err instanceof Error ? err.cause?.message ?? err.message : String(err);
|
|
65
|
+
throw new Error(`Cannot reach Paperclip at ${this.config.apiUrl} (${why}). Check PAPERCLIP_API_URL and that the server is running.`);
|
|
66
|
+
}
|
|
67
|
+
let parsed = null;
|
|
68
|
+
if (text) {
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
parsed = text;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new PaperclipApiError(res.status, method, path, parsed);
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
get = (path, query) => this.request("GET", path, { query });
|
|
81
|
+
post = (path, body) => this.request("POST", path, { body: body ?? {} });
|
|
82
|
+
patch = (path, body) => this.request("PATCH", path, { body });
|
|
83
|
+
}
|