papercliped 0.0.0-stage → 1.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +11 -0
  2. package/.claude-plugin/plugin.json +8 -0
  3. package/.mcp.json +14 -0
  4. package/CHANGELOG.md +25 -0
  5. package/LICENSE +21 -0
  6. package/README.md +121 -2
  7. package/commands/paperclip-report.md +5 -0
  8. package/commands/paperclip-status.md +4 -0
  9. package/commands/paperclip-sync.md +5 -0
  10. package/dist/accounts/alias.js +28 -0
  11. package/dist/accounts/secret.js +65 -0
  12. package/dist/accounts/types.js +2 -0
  13. package/dist/accounts/username.js +38 -0
  14. package/dist/cli.js +87 -0
  15. package/dist/client.js +83 -0
  16. package/dist/config.js +116 -0
  17. package/dist/demo.js +144 -0
  18. package/dist/errors.js +3 -0
  19. package/dist/execute.js +76 -0
  20. package/dist/http.js +125 -0
  21. package/dist/manage/page.js +170 -0
  22. package/dist/manage/routes.js +176 -0
  23. package/dist/mcp.js +36 -0
  24. package/dist/migrate.js +75 -0
  25. package/dist/net/safe-fetch.js +123 -0
  26. package/dist/oauth/crypto.js +57 -0
  27. package/dist/oauth/pages.js +181 -0
  28. package/dist/oauth/paperclip-login.js +97 -0
  29. package/dist/oauth/pg-store.js +444 -0
  30. package/dist/oauth/provider.js +1272 -0
  31. package/dist/oauth/scopes.js +24 -0
  32. package/dist/oauth/store.js +502 -0
  33. package/dist/openapi-cli.js +3 -0
  34. package/dist/openapi.js +41 -0
  35. package/dist/panel/config.js +61 -0
  36. package/dist/panel/main.js +90 -0
  37. package/dist/panel/page.js +553 -0
  38. package/dist/panel/routes.js +284 -0
  39. package/dist/panel/server.js +37 -0
  40. package/dist/reports.js +125 -0
  41. package/dist/server.js +157 -0
  42. package/dist/site/favicon.js +4 -0
  43. package/dist/site/landing.js +169 -0
  44. package/dist/site/markdown.js +76 -0
  45. package/dist/site/site.js +120 -0
  46. package/dist/stdio.js +10 -0
  47. package/dist/store-factory.js +8 -0
  48. package/dist/telemetry/recorder.js +197 -0
  49. package/dist/telemetry/timing.js +29 -0
  50. package/dist/telemetry/types.js +5 -0
  51. package/dist/tools.js +412 -0
  52. package/dist/version.js +3 -0
  53. package/docs/ARCHITECTURE.md +73 -0
  54. package/docs/DEPLOY-RENDER.md +3 -0
  55. package/docs/LAUNCH.md +107 -0
  56. package/docs/SECURITY.md +65 -0
  57. package/docs/chatgpt.md +20 -0
  58. package/docs/least-privilege.sql +23 -0
  59. package/docs/legal/PRIVACY.md +37 -0
  60. package/docs/legal/TERMS.md +11 -0
  61. package/docs/oauth.md +62 -0
  62. package/docs/panel-role.sql +26 -0
  63. package/docs/supabase-schema.sql +281 -0
  64. package/migrations/001_init.sql +84 -0
  65. package/migrations/002_audit.sql +41 -0
  66. package/migrations/003_accounts.sql +66 -0
  67. package/migrations/004_privacy_panel.sql +57 -0
  68. package/migrations/005_beta.sql +2 -0
  69. package/package.json +72 -4
  70. package/site/docs/anonymous-mode.md +18 -0
  71. package/site/docs/chatgpt.md +18 -0
  72. package/site/docs/getting-started.md +41 -0
  73. package/site/docs/manage.md +28 -0
  74. package/site/docs/permissions.md +20 -0
  75. package/site/docs/security.md +25 -0
  76. package/site/docs/signup.md +30 -0
  77. package/site/docs/troubleshooting.md +15 -0
  78. package/site/privacy.md +49 -0
  79. package/site/terms.md +13 -0
  80. package/skills/paperclip/SKILL.md +23 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "papercliped",
3
+ "owner": { "name": "KpnWorld" },
4
+ "plugins": [
5
+ {
6
+ "name": "papercliped",
7
+ "source": "./",
8
+ "description": "Control Paperclip agents, sync with them, and pull reports from Claude."
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "papercliped",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Control Paperclip agents, sync with them, and pull reports from Claude.",
5
+ "author": { "name": "KpnWorld" },
6
+ "license": "MIT",
7
+ "keywords": ["paperclip", "agents", "orchestration", "mcp"]
8
+ }
package/.mcp.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "mcpServers": {
3
+ "paperclip": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/dist/stdio.js"],
6
+ "env": {
7
+ "PAPERCLIP_API_URL": "${PAPERCLIP_API_URL:-http://localhost:3100}",
8
+ "PAPERCLIP_API_KEY": "${PAPERCLIP_API_KEY:-}",
9
+ "PAPERCLIP_COMPANY_ID": "${PAPERCLIP_COMPANY_ID:-}",
10
+ "PAPERCLIP_READ_ONLY": "${PAPERCLIP_READ_ONLY:-}"
11
+ }
12
+ }
13
+ }
14
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## v1.1.0-beta.2 — connection manager, public site
4
+
5
+ - **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).
6
+ - **Full control (beta)** is offered to everyone on the permission screen; Admin only when the app asks for it.
7
+ - **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.
8
+ - Windows-friendly test suite; `npm publish` only builds.
9
+ - Set `SITE_CONTACT` (an email or support URL) so the privacy and terms pages show a real contact.
10
+
11
+ Not in this release: a native Paperclip plugin that embeds the manager inside Paperclip (it needs the Paperclip plugin SDK).
12
+
13
+ ## v1.0.0-beta.1 — first public beta
14
+
15
+ Papercliped connects Claude and ChatGPT to your own Paperclip: control agents, sync state, pull reports.
16
+
17
+ - **MCP server** (stdio + Streamable HTTP) and **ChatGPT Actions** (generated OpenAPI) over Paperclip's REST API.
18
+ - **Hosted multi-tenant bridge** with its own OAuth 2.1 server (DCR, PKCE, rotating refresh tokens, scopes read/control/admin).
19
+ - **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.
20
+ - **Anonymous mode**: show as an alias (e.g. `Ann02`) in logs and the operator panel.
21
+ - **SSRF-hardened egress** for user-supplied Paperclip URLs; sealed (AES-256-GCM) stored credentials with key rotation.
22
+ - **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`).
23
+ - Free-tier friendly: keep-alive worker, database heartbeat, auto-migrate; one-paste Supabase schema (`docs/supabase-schema.sql`).
24
+
25
+ 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
- # Temporary Holding Version
1
+ # papercliped — Claude & ChatGPT plugin for Paperclip
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Drive a [Paperclip](https://github.com/paperclipai/paperclip) instance (AI-agent company orchestrator) from Claude or ChatGPT through Paperclip's REST API.
4
+
5
+ | Goal | How |
6
+ | --- | --- |
7
+ | Control Paperclip and agents | pause / resume / wake / clear-error / terminate agents, set budgets, create & assign issues, manage goals, decide approvals |
8
+ | Sync with agents | `paperclip_sync_snapshot` (state now), `paperclip_sync_changes` (cursor-based "what changed"), `paperclip_wait_for_agent` (follow a run to completion) |
9
+ | Reports | status, costs, agent performance, activity digest — markdown for people plus structured data |
10
+ | Use the API | every tool is a thin call to Paperclip's `/api`; `paperclip_api_request` reaches anything without a dedicated tool |
11
+
12
+ One tool catalogue (`src/tools.ts`, 29 tools) feeds three surfaces, so they cannot drift:
13
+
14
+ ```
15
+ Claude Code / Claude Desktop ──stdio──▶ ┐
16
+ Any MCP client ──Streamable HTTP /mcp─▶ ├─ tools ─▶ Paperclip /api
17
+ ChatGPT Custom GPT ──/actions/* + /openapi.json─▶ ┘
18
+ ```
19
+
20
+ > Paperclip already ships `@paperclipai/mcp-server` for issues/goals/approvals *from inside an agent run*. This bridge is the **operator-side** complement: agent lifecycle control, dashboard/cost/activity reports, sync, and an HTTP transport. They can be installed side by side.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ git clone https://github.com/OpenSourcx/papercliped && cd papercliped
26
+ npm install # also builds (prepare script)
27
+ npm test
28
+ ```
29
+
30
+ ### Credentials (read this first)
31
+
32
+ Pause/resume/wake/approve are **board-only** in Paperclip, so an agent API key will get `403`s. Use a **board API token**:
33
+
34
+ ```sh
35
+ paperclipai auth login # then copy the board token, or use your instance's token flow
36
+ export PAPERCLIP_API_URL=http://localhost:3100
37
+ export PAPERCLIP_API_KEY=<board token> # not needed in local_trusted mode
38
+ export PAPERCLIP_COMPANY_ID=<optional default company>
39
+ export PAPERCLIP_READ_ONLY=1 # optional: reports + sync only, no writes
40
+ ```
41
+
42
+ ## Claude Code (plugin)
43
+
44
+ ```
45
+ /plugin marketplace add OpenSourcx/papercliped
46
+ /plugin install paperclip@papercliped
47
+ ```
48
+
49
+ You get the MCP tools, a `paperclip` skill (sync-first workflow + confirm-before-destructive rules) and `/paperclip-status`, `/paperclip-sync`, `/paperclip-report`.
50
+ The env vars above are read from your shell. Or without the plugin: `claude mcp add paperclip -- node /path/to/papercliped/dist/stdio.js`.
51
+
52
+ ## Claude Desktop
53
+
54
+ ```json
55
+ { "mcpServers": { "paperclip": {
56
+ "command": "node", "args": ["/path/to/papercliped/dist/stdio.js"],
57
+ "env": { "PAPERCLIP_API_URL": "http://localhost:3100", "PAPERCLIP_API_KEY": "…" } } } }
58
+ ```
59
+
60
+ ## ChatGPT (Custom GPT Actions)
61
+
62
+ Run the HTTP bridge somewhere ChatGPT can reach over HTTPS, then import its OpenAPI. Step-by-step: [docs/chatgpt.md](docs/chatgpt.md).
63
+
64
+ ```sh
65
+ export BRIDGE_TOKEN=$(openssl rand -hex 32) # required; the bridge refuses to start without it
66
+ export BRIDGE_PUBLIC_URL=https://bridge.example.com
67
+ npm run start:http # 127.0.0.1:3939 — put TLS in front (Caddy, Cloudflare Tunnel, …)
68
+ ```
69
+
70
+ Endpoints (all but `/healthz`, `/openapi.json` and the OAuth endpoints need `Authorization: Bearer <BRIDGE_TOKEN or OAuth access token>`): `POST /mcp` (MCP Streamable HTTP, stateless), `POST /actions/{tool}` (JSON in/out), `GET /openapi.json`. With `BRIDGE_OAUTH=1`: `/.well-known/oauth-*`, `/register`, `/authorize`, `/token`, `/revoke`.
71
+
72
+ ### Three ways to run it
73
+
74
+ | Mode | For | Auth | State |
75
+ | --- | --- | --- | --- |
76
+ | **stdio** (`node dist/stdio.js`) | you, on your machine (Claude Code/Desktop) | your env vars | none |
77
+ | **HTTP + `BRIDGE_TOKEN`** | ChatGPT Actions, Claude Code headers, one instance | static bearer | none |
78
+ | **HTTP + OAuth, `BRIDGE_MODE=single`** | a team fronting *their one* Paperclip | OAuth 2.1, scoped | JSON file or Postgres |
79
+ | **HTTP + OAuth, `BRIDGE_MODE=multi`** | **a public service**: every user connects their own (publicly reachable, https) Paperclip | OAuth 2.1, scoped | Postgres (required) |
80
+
81
+ **Operator panel** (a separate program, `papercliped-panel`): latency, system faults, success rate, user count and a community log — see [docs/LAUNCH.md §7](docs/LAUNCH.md); preview with `npm run demo`. **Supabase schema:** paste [docs/supabase-schema.sql](docs/supabase-schema.sql) into the SQL editor. **Launch guide (Render free, domain, marketplaces, npm):** [docs/LAUNCH.md](docs/LAUNCH.md).
82
+
83
+ Public hosting on Render + Supabase: **[docs/DEPLOY-RENDER.md](docs/DEPLOY-RENDER.md)** · threat model and residual risks: **[docs/SECURITY.md](docs/SECURITY.md)** · policy templates: [docs/legal/](docs/legal/).
84
+
85
+ ### Connect claude.ai / Claude Desktop / ChatGPT apps (OAuth)
86
+
87
+ Set `BRIDGE_OAUTH=1`, `BRIDGE_PUBLIC_URL`, `BRIDGE_SECRET`, `BRIDGE_DATA_FILE` (see [.env.example](.env.example)), then add `https://<your bridge>/mcp` as a custom connector. Users sign in through Paperclip's own approval page and choose Read / Control / Admin. Full guide, scopes and security model: [docs/oauth.md](docs/oauth.md).
88
+
89
+ ## Safety model
90
+
91
+ - OAuth connections get scoped tokens (read/control/admin), enforced in the bridge, with every call audit-logged. See [docs/oauth.md](docs/oauth.md).
92
+ - `PAPERCLIP_READ_ONLY=1` blocks every mutating tool (and non-GET `paperclip_api_request`) before anything is sent to Paperclip.
93
+ - `paperclip_terminate_agent` needs `confirm: true`; non-GET raw API calls need it too. Tools carry MCP `destructiveHint`/`readOnlyHint`, and OpenAPI `x-openai-isConsequential`, so clients prompt before consequential calls.
94
+ - The HTTP bridge binds to loopback by default, uses constant-time token comparison, caps bodies at 1 MB, and times out upstream calls (30 s).
95
+ - The bridge holds a powerful credential. Anyone with `BRIDGE_TOKEN` controls your agents; scope the Paperclip token and use read-only mode where you can.
96
+
97
+ ## Known limits — please read
98
+
99
+ - **Tested against a mock of Paperclip's documented API, not a live instance.** Endpoint paths and fields were taken from Paperclip's docs and server source (`docs/api/*`, `server/src/routes/*`); report/sync code reads response fields defensively. Run it against your instance with `PAPERCLIP_READ_ONLY=1` first.
100
+ - **claude.ai web / Desktop / mobile connectors and ChatGPT MCP apps** need OAuth: set `BRIDGE_OAUTH=1` (see [docs/oauth.md](docs/oauth.md)). OAuth scopes limit *what* a connection may do, not *which company* it may touch (Paperclip board keys are user-wide; verified in its source). Not yet implemented: CIMD, a grant-admin web UI (use `npm run admin`).
101
+ - **A public bridge holds other people's Paperclip keys.** A bridge compromise exposes all of them until revoked. Read [docs/SECURITY.md](docs/SECURITY.md) before opening it up. The code is unaudited and the Render/Supabase/claude.ai/ChatGPT specifics are untested against the live services.
102
+ - **Hosted mode only reaches Paperclips on the public internet over https.** `localhost`/private-network instances use the local stdio plugin.
103
+ - `paperclip_sync_changes` polls the activity log (no push). Activity has no server-side `since` filter, so it fetches up to `limit` recent entries and filters locally; raise `limit` if you poll infrequently on a busy company.
104
+ - ChatGPT Actions allow ~30 operations per GPT; the catalogue is at 29. Adding tools means removing some for that surface.
105
+
106
+ ## Layout
107
+
108
+ ```
109
+ src/tools.ts tool catalogue (schemas + Paperclip calls) src/reports.ts report builders
110
+ src/execute.ts validation, read-only enforcement, errors src/openapi.ts OpenAPI from the catalogue
111
+ src/mcp.ts MCP server src/stdio.ts src/server.ts + http.ts HTTP bridge
112
+ src/oauth/ OAuth 2.1 server: provider, stores (memory/JSON + Postgres), crypto/key ring, scopes, consent pages, Paperclip login adapter
113
+ src/telemetry/ audit events, batching recorder, system sampler, timing src/panel/ separate operator panel (routes, page, config, server)
114
+ src/net/ SSRF-safe fetch + instance URL validation (multi-tenant egress guard)
115
+ migrations/ Postgres schema (dedicated `bridge` schema, RLS) Dockerfile container image (Render free web service) src/cli.ts admin CLI
116
+ .claude-plugin/ .mcp.json skills/ commands/ Claude Code plugin
117
+ test/ end-to-end tests against a mock Paperclip
118
+ ```
119
+
120
+ ## Privacy
121
+
122
+ Local modes: no telemetry and no backend; the bridge sends requests only to the Paperclip instance you configure and returns responses to the AI client that called it. Your credential lives in environment variables on your machine. Hosted mode (OAuth) additionally stores, per connection, an encrypted Paperclip key, hashed tokens and an audit log — see [docs/legal/PRIVACY.md](docs/legal/PRIVACY.md) (template) and [docs/SECURITY.md](docs/SECURITY.md). Data you expose to Claude or ChatGPT is then subject to that provider's own terms and privacy policy. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the security model and distribution plan.
@@ -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 panel 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,2 @@
1
+ export class AliasTakenError extends Error {
2
+ }
@@ -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", "kpnsolute", "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
+ }