papercliped 1.1.0-beta.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +70 -1
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/commands/paperclip-report.md +5 -5
- package/commands/paperclip-status.md +4 -4
- package/commands/paperclip-sync.md +5 -5
- package/dist/accounts/alias.js +1 -1
- package/dist/accounts/username.js +1 -1
- package/dist/cli.js +11 -11
- package/dist/http.js +17 -3
- package/dist/manage/routes.js +60 -82
- package/dist/oauth/pages.js +32 -18
- package/dist/oauth/paperclip-login.js +38 -0
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +83 -64
- package/dist/oauth/scopes.js +19 -16
- package/dist/openapi.js +19 -2
- package/dist/public-api/health.js +46 -0
- package/dist/public-api/openapi.js +51 -0
- package/dist/public-api/routes.js +251 -0
- package/dist/public-api/types.js +14 -0
- package/dist/server.js +4 -0
- package/dist/site/landing.js +3 -3
- package/dist/site/markdown.js +2 -1
- package/dist/site/site.js +16 -4
- package/dist/telemetry/recorder.js +1 -1
- package/dist/tools.js +48 -2
- package/dist/web/csp.js +16 -0
- package/dist/web/hosts.js +23 -0
- package/dist/web/routes.js +132 -0
- package/docs/ARCHITECTURE.md +74 -73
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -3
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +50 -34
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -65
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -20
- package/docs/least-privilege.sql +23 -23
- package/docs/legal/PRIVACY.md +37 -37
- package/docs/legal/TERMS.md +11 -11
- package/docs/oauth.md +61 -62
- package/docs/supabase-schema.sql +8 -1
- package/migrations/001_init.sql +84 -84
- package/migrations/002_audit.sql +41 -41
- package/migrations/003_accounts.sql +66 -66
- package/migrations/004_privacy_panel.sql +57 -57
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +10 -14
- package/site/docs/chatgpt.md +1 -1
- package/site/docs/cli.md +27 -0
- package/site/docs/connect-your-paperclip.md +120 -0
- package/site/docs/environment.md +64 -0
- package/site/docs/faq.md +31 -0
- package/site/docs/getting-started.md +11 -5
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +12 -21
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +36 -0
- package/site/docs/permissions.md +5 -5
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +2 -2
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +3 -3
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +2 -2
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +5 -3
- package/site/terms.md +1 -1
- package/skills/paperclip/SKILL.md +23 -23
- package/dist/demo.js +0 -144
- package/dist/manage/page.js +0 -170
- package/dist/panel/config.js +0 -61
- package/dist/panel/main.js +0 -90
- package/dist/panel/page.js +0 -553
- package/dist/panel/routes.js +0 -284
- package/dist/panel/server.js +0 -37
- package/docs/panel-role.sql +0 -26
package/docs/oauth.md
CHANGED
|
@@ -1,62 +1,61 @@
|
|
|
1
|
-
# OAuth (Phase 2): connect claude.ai, Claude Desktop/mobile and ChatGPT apps
|
|
2
|
-
|
|
3
|
-
With `BRIDGE_OAUTH=1` the bridge is an OAuth 2.1 authorization server **and** the protected resource (`/mcp`). Clients discover it, register themselves, and send the user through a consent page. The user signs in with **Paperclip's own approval flow**, so the bridge never handles their password.
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
client ──401 + resource_metadata──▶ bridge /mcp
|
|
7
|
-
client ──DCR /register──────────────▶ bridge
|
|
8
|
-
client ──/authorize (PKCE S256)─────▶ bridge ──create challenge──▶ Paperclip /api/cli-auth/challenges
|
|
9
|
-
user ── opens approvalUrl, signs in to Paperclip, approves ─────▶ Paperclip
|
|
10
|
-
user ── picks Read /
|
|
11
|
-
client ──/token (code + verifier)───▶ bridge ──▶ opaque access + rotating refresh token
|
|
12
|
-
client ──/mcp Bearer pcb_at_…──────▶ bridge ──user's board token──▶ Paperclip
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## Setup (self-hosted)
|
|
16
|
-
|
|
17
|
-
```sh
|
|
18
|
-
export PAPERCLIP_API_URL=http://localhost:3100
|
|
19
|
-
export PAPERCLIP_PUBLIC_URL=https://paperclip.example.com # where the USER's browser reaches Paperclip
|
|
20
|
-
export BRIDGE_OAUTH=1
|
|
21
|
-
export BRIDGE_PUBLIC_URL=https://bridge.example.com # https origin, no path
|
|
22
|
-
export BRIDGE_SECRET=$(openssl rand -hex 32) # keep it; losing/rotating it disconnects everyone
|
|
23
|
-
export BRIDGE_DATA_FILE=/var/lib/paperclip-bridge/state.json # omit = memory only (restart disconnects everyone)
|
|
24
|
-
npm run start:http # put TLS in front (Caddy / Cloudflare Tunnel / …)
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
- **claude.ai / Desktop / mobile:** Settings → Connectors → *Add custom connector* → `https://bridge.example.com/mcp`. Leave client ID/secret empty (Claude registers itself). Allow Anthropic's egress range `160.79.104.0/21` if you firewall.
|
|
28
|
-
- **Claude Code:** `claude mcp add --transport http paperclip https://bridge.example.com/mcp`, then `/mcp` to sign in. (Or keep using stdio.)
|
|
29
|
-
- **ChatGPT MCP app (developer mode):** point at the same `/mcp` URL. **[unverified]** ChatGPT's redirect hosts are in the default allow-list from secondary sources; if sign-in is refused with "redirect host … not allowed", add the host to `BRIDGE_OAUTH_REDIRECT_HOSTS`.
|
|
30
|
-
- **Instances without the approval flow** (or `local_trusted` with remote clients): `BRIDGE_LOGIN=static` + `BRIDGE_TOKEN`. The consent page then asks the operator for `BRIDGE_TOKEN` and the bridge uses `PAPERCLIP_API_KEY` for upstream calls. Everyone who connects shares that one Paperclip identity.
|
|
31
|
-
|
|
32
|
-
## Scopes (enforced by the bridge — Paperclip's keys have none)
|
|
33
|
-
|
|
34
|
-
| Scope | Allows |
|
|
35
|
-
| --- | --- |
|
|
36
|
-
| `paperclip:read` | lists, reports, sync, raw GET |
|
|
37
|
-
| `paperclip:control` | + pause/resume/wake/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- `BRIDGE_MODE=
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
CIMD (Client ID Metadata Documents; the new SSRF-safe fetch makes it feasible), per-company scoping (blocked on Paperclip), a grant-admin web UI (CLI exists), and Anthropic-held client credentials. DCR is the only registration path, so very busy public deployments will accumulate registered clients (idle ones are pruned after 7 days; capped at 1000).
|
|
1
|
+
# OAuth (Phase 2): connect claude.ai, Claude Desktop/mobile and ChatGPT apps
|
|
2
|
+
|
|
3
|
+
With `BRIDGE_OAUTH=1` the bridge is an OAuth 2.1 authorization server **and** the protected resource (`/mcp`). Clients discover it, register themselves, and send the user through a consent page. The user signs in with **Paperclip's own approval flow**, so the bridge never handles their password.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
client ──401 + resource_metadata──▶ bridge /mcp
|
|
7
|
+
client ──DCR /register──────────────▶ bridge
|
|
8
|
+
client ──/authorize (PKCE S256)─────▶ bridge ──create challenge──▶ Paperclip /api/cli-auth/challenges
|
|
9
|
+
user ── opens approvalUrl, signs in to Paperclip, approves ─────▶ Paperclip
|
|
10
|
+
user ── picks Read only / Full control, presses Allow ────────▶ bridge (verifies approval, checks token)
|
|
11
|
+
client ──/token (code + verifier)───▶ bridge ──▶ opaque access + rotating refresh token
|
|
12
|
+
client ──/mcp Bearer pcb_at_…──────▶ bridge ──user's board token──▶ Paperclip
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup (self-hosted)
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
export PAPERCLIP_API_URL=http://localhost:3100
|
|
19
|
+
export PAPERCLIP_PUBLIC_URL=https://paperclip.example.com # where the USER's browser reaches Paperclip
|
|
20
|
+
export BRIDGE_OAUTH=1
|
|
21
|
+
export BRIDGE_PUBLIC_URL=https://bridge.example.com # https origin, no path
|
|
22
|
+
export BRIDGE_SECRET=$(openssl rand -hex 32) # keep it; losing/rotating it disconnects everyone
|
|
23
|
+
export BRIDGE_DATA_FILE=/var/lib/paperclip-bridge/state.json # omit = memory only (restart disconnects everyone)
|
|
24
|
+
npm run start:http # put TLS in front (Caddy / Cloudflare Tunnel / …)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **claude.ai / Desktop / mobile:** Settings → Connectors → *Add custom connector* → `https://bridge.example.com/mcp`. Leave client ID/secret empty (Claude registers itself). Allow Anthropic's egress range `160.79.104.0/21` if you firewall.
|
|
28
|
+
- **Claude Code:** `claude mcp add --transport http paperclip https://bridge.example.com/mcp`, then `/mcp` to sign in. (Or keep using stdio.)
|
|
29
|
+
- **ChatGPT MCP app (developer mode):** point at the same `/mcp` URL. **[unverified]** ChatGPT's redirect hosts are in the default allow-list from secondary sources; if sign-in is refused with "redirect host … not allowed", add the host to `BRIDGE_OAUTH_REDIRECT_HOSTS`.
|
|
30
|
+
- **Instances without the approval flow** (or `local_trusted` with remote clients): `BRIDGE_LOGIN=static` + `BRIDGE_TOKEN`. The consent page then asks the operator for `BRIDGE_TOKEN` and the bridge uses `PAPERCLIP_API_KEY` for upstream calls. Everyone who connects shares that one Paperclip identity.
|
|
31
|
+
|
|
32
|
+
## Scopes (enforced by the bridge — Paperclip's keys have none)
|
|
33
|
+
|
|
34
|
+
| Scope | Allows |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `paperclip:read` | lists, reports, sync, raw GET |
|
|
37
|
+
| `paperclip:control` | every tool: + pause/resume/wake/terminate, issues & goals, comments, approvals, budgets, raw non-GET |
|
|
38
|
+
|
|
39
|
+
Control includes read. The user picks a level on the consent page (Read only is preselected unless the app asked for control). The retired `paperclip:admin` scope is accepted as an alias for `paperclip:control`. Tools above the grant aren't even advertised to the model, and a call is refused with `403 insufficient_scope`.
|
|
40
|
+
**What scopes do not do:** limit *which company* or agent a grant can touch. The underlying Paperclip board key is user-wide, and the bridge cannot tell which company an agent/issue id belongs to. Per-company scoping needs scoped board keys upstream (Phase 3).
|
|
41
|
+
|
|
42
|
+
## Security properties (each covered by a test in `test/oauth.test.ts`)
|
|
43
|
+
|
|
44
|
+
- PKCE `S256` mandatory; single-use 60 s codes; **code replay revokes the tokens it produced**; `client_id`/`redirect_uri` re-checked at exchange.
|
|
45
|
+
- Never redirects to an unregistered `redirect_uri`; registration only accepts allow-listed https hosts + loopback (port-agnostic per RFC 8252); public clients only.
|
|
46
|
+
- Opaque tokens, only SHA-256 hashes stored; refresh tokens rotate and **reuse revokes the whole grant** and the Paperclip key (`revoke-current`).
|
|
47
|
+
- The Paperclip credential is sealed with AES-256-GCM (key derived from `BRIDGE_SECRET`) and deleted on revoke; it is never returned to any client.
|
|
48
|
+
- Consent page: CSRF token, CSP (`frame-ancestors 'none'`), escaped client names, loopback warning, rate limits on authorize/decision/token/register and on admin-token guesses.
|
|
49
|
+
- Every tool call is audit-logged as a JSON line (tool, mutation, ok/status, grant id, client name, Paperclip user id) — never tokens or arguments.
|
|
50
|
+
- Access tokens last 1 h, refresh 30 d (sliding). Operators can revoke a grant via `OAuthProvider.revokeGrant`; users via the client's "disconnect" (`/revoke`).
|
|
51
|
+
|
|
52
|
+
## Single vs multi-tenant, and storage
|
|
53
|
+
|
|
54
|
+
- `BRIDGE_MODE=single` (default): the bridge fronts the one Paperclip at `PAPERCLIP_API_URL`. State: a JSON file (`BRIDGE_DATA_FILE`, single process) or Postgres (`DATABASE_URL`).
|
|
55
|
+
- `BRIDGE_MODE=multi`: a **public** service. The consent page first asks the user for *their* Paperclip address; the bridge validates it (https, public DNS name only — see `src/net/safe-fetch.ts`), probes `/api/health`, starts the Paperclip approval flow **on that instance**, and binds the grant to it. Requires Postgres, `BRIDGE_LOGIN=paperclip`, and no `BRIDGE_TOKEN`. Deployment: [DEPLOY-RENDER.md](DEPLOY-RENDER.md). Threat model: [SECURITY.md](SECURITY.md).
|
|
56
|
+
- The Postgres store (`src/oauth/pg-store.ts`) is multi-process safe: consuming a code/refresh token and rate-limit counting are single atomic statements, and consent state lives in the database too. Migrations: `npm run migrate`.
|
|
57
|
+
- FastAPI/Python: not needed — the Node service is the tested implementation; rewriting would duplicate the security-critical logic.
|
|
58
|
+
|
|
59
|
+
## Not implemented yet
|
|
60
|
+
|
|
61
|
+
CIMD (Client ID Metadata Documents; the new SSRF-safe fetch makes it feasible), per-company scoping (blocked on Paperclip), a grant-admin web UI (CLI exists), and Anthropic-held client credentials. DCR is the only registration path, so very busy public deployments will accumulate registered clients (idle ones are pruned after 7 days; capped at 1000).
|
package/docs/supabase-schema.sql
CHANGED
|
@@ -267,6 +267,13 @@ end $$;
|
|
|
267
267
|
-- Beta opt-in: accounts that join the beta get the connection manager (/manage) and its management API.
|
|
268
268
|
alter table bridge.accounts add column if not exists beta boolean not null default false;
|
|
269
269
|
|
|
270
|
+
-- ───── 006_two_levels.sql ─────
|
|
271
|
+
-- Two access levels: Read only and Full control. The old third level (paperclip:admin) is folded into Full control,
|
|
272
|
+
-- which now covers every tool. Existing grants keep working; this only tidies the stored scope lists.
|
|
273
|
+
update bridge.grants
|
|
274
|
+
set scopes = array(select distinct s from unnest(array_replace(scopes, 'paperclip:admin', 'paperclip:control')) as s order by s desc)
|
|
275
|
+
where 'paperclip:admin' = any(scopes);
|
|
276
|
+
|
|
270
277
|
-- ───── migration bookkeeping ─────
|
|
271
278
|
create table if not exists bridge.schema_migrations (version text primary key, applied_at timestamptz not null default now());
|
|
272
279
|
alter table bridge.schema_migrations enable row level security;
|
|
@@ -276,6 +283,6 @@ do $$ declare r text; begin
|
|
|
276
283
|
if exists (select 1 from pg_roles where rolname = r) then execute format('revoke all on bridge.schema_migrations from %I', r); end if;
|
|
277
284
|
end loop;
|
|
278
285
|
end $$;
|
|
279
|
-
insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql') on conflict (version) do nothing;
|
|
286
|
+
insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql') on conflict (version) do nothing;
|
|
280
287
|
|
|
281
288
|
commit;
|
package/migrations/001_init.sql
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
-- Paperclip Bridge schema. Run with a direct/session connection (see docs/DEPLOY-RENDER.md).
|
|
2
|
-
-- Everything lives in its own schema, which Supabase does NOT expose through its REST/GraphQL Data API,
|
|
3
|
-
-- and is additionally locked down with RLS (no policies = deny) and revoked grants, so the public `anon`
|
|
4
|
-
-- key can never read it. The bridge connects as a privileged role over a normal Postgres connection.
|
|
5
|
-
|
|
6
|
-
create schema if not exists bridge;
|
|
7
|
-
|
|
8
|
-
create table if not exists bridge.clients (
|
|
9
|
-
id text primary key,
|
|
10
|
-
name text not null,
|
|
11
|
-
redirect_uris text[] not null,
|
|
12
|
-
created_at bigint not null,
|
|
13
|
-
last_used_at bigint not null
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
create table if not exists bridge.grants (
|
|
17
|
-
id text primary key,
|
|
18
|
-
client_id text not null,
|
|
19
|
-
client_name text not null,
|
|
20
|
-
user_id text,
|
|
21
|
-
scopes text[] not null,
|
|
22
|
-
resource text not null,
|
|
23
|
-
instance_url text,
|
|
24
|
-
sealed_credential text, -- AES-256-GCM sealed in the app; the key never touches the database
|
|
25
|
-
created_at bigint not null,
|
|
26
|
-
last_used_at bigint not null,
|
|
27
|
-
revoked boolean not null default false
|
|
28
|
-
);
|
|
29
|
-
create index if not exists grants_idle_idx on bridge.grants (last_used_at) where not revoked;
|
|
30
|
-
|
|
31
|
-
create table if not exists bridge.tokens (
|
|
32
|
-
hash text primary key, -- sha256 of the raw token; raw tokens are never stored
|
|
33
|
-
kind text not null check (kind in ('access', 'refresh')),
|
|
34
|
-
grant_id text not null,
|
|
35
|
-
expires_at bigint not null,
|
|
36
|
-
consumed boolean not null default false
|
|
37
|
-
);
|
|
38
|
-
create index if not exists tokens_grant_idx on bridge.tokens (grant_id);
|
|
39
|
-
create index if not exists tokens_expiry_idx on bridge.tokens (expires_at);
|
|
40
|
-
|
|
41
|
-
create table if not exists bridge.pending (
|
|
42
|
-
rid text primary key,
|
|
43
|
-
data jsonb not null, -- sensitive parts already sealed by the app
|
|
44
|
-
expires_at bigint not null
|
|
45
|
-
);
|
|
46
|
-
create index if not exists pending_expiry_idx on bridge.pending (expires_at);
|
|
47
|
-
|
|
48
|
-
create table if not exists bridge.codes (
|
|
49
|
-
hash text primary key,
|
|
50
|
-
data jsonb not null,
|
|
51
|
-
expires_at bigint not null,
|
|
52
|
-
used boolean not null default false,
|
|
53
|
-
grant_id text
|
|
54
|
-
);
|
|
55
|
-
create index if not exists codes_expiry_idx on bridge.codes (expires_at);
|
|
56
|
-
|
|
57
|
-
create table if not exists bridge.rate_limits (
|
|
58
|
-
key text primary key,
|
|
59
|
-
n integer not null,
|
|
60
|
-
reset_at bigint not null
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
-- Defence in depth: deny by default for every non-owner role.
|
|
64
|
-
alter table bridge.clients enable row level security;
|
|
65
|
-
alter table bridge.grants enable row level security;
|
|
66
|
-
alter table bridge.tokens enable row level security;
|
|
67
|
-
alter table bridge.pending enable row level security;
|
|
68
|
-
alter table bridge.codes enable row level security;
|
|
69
|
-
alter table bridge.rate_limits enable row level security;
|
|
70
|
-
|
|
71
|
-
revoke all on schema bridge from public;
|
|
72
|
-
revoke all on all tables in schema bridge from public;
|
|
73
|
-
|
|
74
|
-
do $$
|
|
75
|
-
declare r text;
|
|
76
|
-
begin
|
|
77
|
-
foreach r in array array['anon', 'authenticated'] loop
|
|
78
|
-
if exists (select 1 from pg_roles where rolname = r) then
|
|
79
|
-
execute format('revoke all on schema bridge from %I', r);
|
|
80
|
-
execute format('revoke all on all tables in schema bridge from %I', r);
|
|
81
|
-
execute format('alter default privileges in schema bridge revoke all on tables from %I', r);
|
|
82
|
-
end if;
|
|
83
|
-
end loop;
|
|
84
|
-
end $$;
|
|
1
|
+
-- Paperclip Bridge schema. Run with a direct/session connection (see docs/DEPLOY-RENDER.md).
|
|
2
|
+
-- Everything lives in its own schema, which Supabase does NOT expose through its REST/GraphQL Data API,
|
|
3
|
+
-- and is additionally locked down with RLS (no policies = deny) and revoked grants, so the public `anon`
|
|
4
|
+
-- key can never read it. The bridge connects as a privileged role over a normal Postgres connection.
|
|
5
|
+
|
|
6
|
+
create schema if not exists bridge;
|
|
7
|
+
|
|
8
|
+
create table if not exists bridge.clients (
|
|
9
|
+
id text primary key,
|
|
10
|
+
name text not null,
|
|
11
|
+
redirect_uris text[] not null,
|
|
12
|
+
created_at bigint not null,
|
|
13
|
+
last_used_at bigint not null
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create table if not exists bridge.grants (
|
|
17
|
+
id text primary key,
|
|
18
|
+
client_id text not null,
|
|
19
|
+
client_name text not null,
|
|
20
|
+
user_id text,
|
|
21
|
+
scopes text[] not null,
|
|
22
|
+
resource text not null,
|
|
23
|
+
instance_url text,
|
|
24
|
+
sealed_credential text, -- AES-256-GCM sealed in the app; the key never touches the database
|
|
25
|
+
created_at bigint not null,
|
|
26
|
+
last_used_at bigint not null,
|
|
27
|
+
revoked boolean not null default false
|
|
28
|
+
);
|
|
29
|
+
create index if not exists grants_idle_idx on bridge.grants (last_used_at) where not revoked;
|
|
30
|
+
|
|
31
|
+
create table if not exists bridge.tokens (
|
|
32
|
+
hash text primary key, -- sha256 of the raw token; raw tokens are never stored
|
|
33
|
+
kind text not null check (kind in ('access', 'refresh')),
|
|
34
|
+
grant_id text not null,
|
|
35
|
+
expires_at bigint not null,
|
|
36
|
+
consumed boolean not null default false
|
|
37
|
+
);
|
|
38
|
+
create index if not exists tokens_grant_idx on bridge.tokens (grant_id);
|
|
39
|
+
create index if not exists tokens_expiry_idx on bridge.tokens (expires_at);
|
|
40
|
+
|
|
41
|
+
create table if not exists bridge.pending (
|
|
42
|
+
rid text primary key,
|
|
43
|
+
data jsonb not null, -- sensitive parts already sealed by the app
|
|
44
|
+
expires_at bigint not null
|
|
45
|
+
);
|
|
46
|
+
create index if not exists pending_expiry_idx on bridge.pending (expires_at);
|
|
47
|
+
|
|
48
|
+
create table if not exists bridge.codes (
|
|
49
|
+
hash text primary key,
|
|
50
|
+
data jsonb not null,
|
|
51
|
+
expires_at bigint not null,
|
|
52
|
+
used boolean not null default false,
|
|
53
|
+
grant_id text
|
|
54
|
+
);
|
|
55
|
+
create index if not exists codes_expiry_idx on bridge.codes (expires_at);
|
|
56
|
+
|
|
57
|
+
create table if not exists bridge.rate_limits (
|
|
58
|
+
key text primary key,
|
|
59
|
+
n integer not null,
|
|
60
|
+
reset_at bigint not null
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
-- Defence in depth: deny by default for every non-owner role.
|
|
64
|
+
alter table bridge.clients enable row level security;
|
|
65
|
+
alter table bridge.grants enable row level security;
|
|
66
|
+
alter table bridge.tokens enable row level security;
|
|
67
|
+
alter table bridge.pending enable row level security;
|
|
68
|
+
alter table bridge.codes enable row level security;
|
|
69
|
+
alter table bridge.rate_limits enable row level security;
|
|
70
|
+
|
|
71
|
+
revoke all on schema bridge from public;
|
|
72
|
+
revoke all on all tables in schema bridge from public;
|
|
73
|
+
|
|
74
|
+
do $$
|
|
75
|
+
declare r text;
|
|
76
|
+
begin
|
|
77
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
78
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
79
|
+
execute format('revoke all on schema bridge from %I', r);
|
|
80
|
+
execute format('revoke all on all tables in schema bridge from %I', r);
|
|
81
|
+
execute format('alter default privileges in schema bridge revoke all on tables from %I', r);
|
|
82
|
+
end if;
|
|
83
|
+
end loop;
|
|
84
|
+
end $$;
|
package/migrations/002_audit.sql
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
-- Audit & latency telemetry. One row per tool call and per OAuth/MCP HTTP request.
|
|
2
|
-
-- NEVER contains tool arguments, tool results, tokens or credentials: only who/what/how-long/outcome.
|
|
3
|
-
-- Retention is enforced by the app (BRIDGE_AUDIT_RETENTION_DAYS, default 30); rows hold the user id and tenant host, so keep it short.
|
|
4
|
-
|
|
5
|
-
create table if not exists bridge.audit_events (
|
|
6
|
-
id bigint generated always as identity primary key,
|
|
7
|
-
ts bigint not null, -- ms since epoch (UTC)
|
|
8
|
-
node text not null, -- which bridge instance wrote it
|
|
9
|
-
kind text not null check (kind in ('tool', 'http')),
|
|
10
|
-
name text not null, -- tool name, or route group such as 'oauth.token'
|
|
11
|
-
mutation boolean not null default false,
|
|
12
|
-
ok boolean not null,
|
|
13
|
-
status integer,
|
|
14
|
-
error_class text, -- invalid_input | insufficient_scope | read_only | upstream_4xx | upstream_5xx | upstream_unreachable | rate_limited | unauthorized | internal
|
|
15
|
-
total_ms integer not null, -- end to end inside the bridge
|
|
16
|
-
upstream_ms integer, -- of which: waiting on the user's Paperclip (union of parallel requests)
|
|
17
|
-
upstream_calls integer,
|
|
18
|
-
scope text, -- scope the call required
|
|
19
|
-
grant_id text,
|
|
20
|
-
client text,
|
|
21
|
-
instance text, -- tenant Paperclip host
|
|
22
|
-
user_id text
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
create index if not exists audit_ts_idx on bridge.audit_events (ts desc);
|
|
26
|
-
create index if not exists audit_kind_name_idx on bridge.audit_events (kind, name, ts desc);
|
|
27
|
-
create index if not exists audit_instance_idx on bridge.audit_events (instance, ts desc) where instance is not null;
|
|
28
|
-
create index if not exists audit_errors_idx on bridge.audit_events (ts desc) where not ok;
|
|
29
|
-
|
|
30
|
-
alter table bridge.audit_events enable row level security;
|
|
31
|
-
revoke all on bridge.audit_events from public;
|
|
32
|
-
|
|
33
|
-
do $$
|
|
34
|
-
declare r text;
|
|
35
|
-
begin
|
|
36
|
-
foreach r in array array['anon', 'authenticated'] loop
|
|
37
|
-
if exists (select 1 from pg_roles where rolname = r) then
|
|
38
|
-
execute format('revoke all on bridge.audit_events from %I', r);
|
|
39
|
-
end if;
|
|
40
|
-
end loop;
|
|
41
|
-
end $$;
|
|
1
|
+
-- Audit & latency telemetry. One row per tool call and per OAuth/MCP HTTP request.
|
|
2
|
+
-- NEVER contains tool arguments, tool results, tokens or credentials: only who/what/how-long/outcome.
|
|
3
|
+
-- Retention is enforced by the app (BRIDGE_AUDIT_RETENTION_DAYS, default 30); rows hold the user id and tenant host, so keep it short.
|
|
4
|
+
|
|
5
|
+
create table if not exists bridge.audit_events (
|
|
6
|
+
id bigint generated always as identity primary key,
|
|
7
|
+
ts bigint not null, -- ms since epoch (UTC)
|
|
8
|
+
node text not null, -- which bridge instance wrote it
|
|
9
|
+
kind text not null check (kind in ('tool', 'http')),
|
|
10
|
+
name text not null, -- tool name, or route group such as 'oauth.token'
|
|
11
|
+
mutation boolean not null default false,
|
|
12
|
+
ok boolean not null,
|
|
13
|
+
status integer,
|
|
14
|
+
error_class text, -- invalid_input | insufficient_scope | read_only | upstream_4xx | upstream_5xx | upstream_unreachable | rate_limited | unauthorized | internal
|
|
15
|
+
total_ms integer not null, -- end to end inside the bridge
|
|
16
|
+
upstream_ms integer, -- of which: waiting on the user's Paperclip (union of parallel requests)
|
|
17
|
+
upstream_calls integer,
|
|
18
|
+
scope text, -- scope the call required
|
|
19
|
+
grant_id text,
|
|
20
|
+
client text,
|
|
21
|
+
instance text, -- tenant Paperclip host
|
|
22
|
+
user_id text
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
create index if not exists audit_ts_idx on bridge.audit_events (ts desc);
|
|
26
|
+
create index if not exists audit_kind_name_idx on bridge.audit_events (kind, name, ts desc);
|
|
27
|
+
create index if not exists audit_instance_idx on bridge.audit_events (instance, ts desc) where instance is not null;
|
|
28
|
+
create index if not exists audit_errors_idx on bridge.audit_events (ts desc) where not ok;
|
|
29
|
+
|
|
30
|
+
alter table bridge.audit_events enable row level security;
|
|
31
|
+
revoke all on bridge.audit_events from public;
|
|
32
|
+
|
|
33
|
+
do $$
|
|
34
|
+
declare r text;
|
|
35
|
+
begin
|
|
36
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
37
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
38
|
+
execute format('revoke all on bridge.audit_events from %I', r);
|
|
39
|
+
end if;
|
|
40
|
+
end loop;
|
|
41
|
+
end $$;
|
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
-- Papercliped accounts: username + secret key, one stored Paperclip connection per account, and the user event log.
|
|
2
|
-
-- Nothing here stores a plaintext secret: `secret_hash` is a salted scrypt hash, `sealed_credential` is AES-GCM sealed in the app.
|
|
3
|
-
|
|
4
|
-
create table if not exists bridge.accounts (
|
|
5
|
-
id text primary key,
|
|
6
|
-
username text not null, -- as typed; shown only in the operator's logs
|
|
7
|
-
username_key text not null, -- lowercased; unique
|
|
8
|
-
secret_hash text not null, -- scrypt$N$r$p$salt$hash of the generated secret key
|
|
9
|
-
created_at bigint not null,
|
|
10
|
-
last_login_at bigint,
|
|
11
|
-
disabled boolean not null default false
|
|
12
|
-
);
|
|
13
|
-
create unique index if not exists accounts_username_key_idx on bridge.accounts (username_key);
|
|
14
|
-
|
|
15
|
-
-- One connection per account. A given Paperclip identity (instance + user id) can belong to only one account.
|
|
16
|
-
create table if not exists bridge.account_links (
|
|
17
|
-
account_id text primary key references bridge.accounts (id) on delete cascade,
|
|
18
|
-
instance_url text not null,
|
|
19
|
-
paperclip_user_id text,
|
|
20
|
-
sealed_credential text, -- null once dropped (idle expiry / disconnect)
|
|
21
|
-
created_at bigint not null,
|
|
22
|
-
connected_at bigint not null,
|
|
23
|
-
last_used_at bigint not null
|
|
24
|
-
);
|
|
25
|
-
create unique index if not exists account_links_identity_idx on bridge.account_links (instance_url, paperclip_user_id) where paperclip_user_id is not null;
|
|
26
|
-
create index if not exists account_links_idle_idx on bridge.account_links (last_used_at) where sealed_credential is not null;
|
|
27
|
-
|
|
28
|
-
-- The community log: joined / left / updated / login / ... (see src/accounts/types.ts). `detail` is a short non-sensitive reason.
|
|
29
|
-
create table if not exists bridge.user_events (
|
|
30
|
-
id bigint generated always as identity primary key,
|
|
31
|
-
ts bigint not null,
|
|
32
|
-
account_id text,
|
|
33
|
-
username text,
|
|
34
|
-
kind text not null,
|
|
35
|
-
detail text
|
|
36
|
-
);
|
|
37
|
-
create index if not exists user_events_ts_idx on bridge.user_events (ts desc);
|
|
38
|
-
create index if not exists user_events_kind_idx on bridge.user_events (kind, ts desc);
|
|
39
|
-
|
|
40
|
-
-- A row the app rewrites on a timer so a free-tier Supabase project always sees recent write activity.
|
|
41
|
-
create table if not exists bridge.heartbeat (
|
|
42
|
-
id integer primary key,
|
|
43
|
-
at bigint not null
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
alter table bridge.grants add column if not exists account_id text;
|
|
47
|
-
alter table bridge.grants add column if not exists username text;
|
|
48
|
-
alter table bridge.audit_events add column if not exists username text;
|
|
49
|
-
create index if not exists grants_account_idx on bridge.grants (account_id) where not revoked;
|
|
50
|
-
create index if not exists audit_username_idx on bridge.audit_events (username, ts desc) where username is not null;
|
|
51
|
-
|
|
52
|
-
alter table bridge.accounts enable row level security;
|
|
53
|
-
alter table bridge.account_links enable row level security;
|
|
54
|
-
alter table bridge.user_events enable row level security;
|
|
55
|
-
alter table bridge.heartbeat enable row level security;
|
|
56
|
-
revoke all on bridge.accounts, bridge.account_links, bridge.user_events, bridge.heartbeat from public;
|
|
57
|
-
|
|
58
|
-
do $$
|
|
59
|
-
declare r text;
|
|
60
|
-
begin
|
|
61
|
-
foreach r in array array['anon', 'authenticated'] loop
|
|
62
|
-
if exists (select 1 from pg_roles where rolname = r) then
|
|
63
|
-
execute format('revoke all on bridge.accounts, bridge.account_links, bridge.user_events, bridge.heartbeat from %I', r);
|
|
64
|
-
end if;
|
|
65
|
-
end loop;
|
|
66
|
-
end $$;
|
|
1
|
+
-- Papercliped accounts: username + secret key, one stored Paperclip connection per account, and the user event log.
|
|
2
|
+
-- Nothing here stores a plaintext secret: `secret_hash` is a salted scrypt hash, `sealed_credential` is AES-GCM sealed in the app.
|
|
3
|
+
|
|
4
|
+
create table if not exists bridge.accounts (
|
|
5
|
+
id text primary key,
|
|
6
|
+
username text not null, -- as typed; shown only in the operator's logs
|
|
7
|
+
username_key text not null, -- lowercased; unique
|
|
8
|
+
secret_hash text not null, -- scrypt$N$r$p$salt$hash of the generated secret key
|
|
9
|
+
created_at bigint not null,
|
|
10
|
+
last_login_at bigint,
|
|
11
|
+
disabled boolean not null default false
|
|
12
|
+
);
|
|
13
|
+
create unique index if not exists accounts_username_key_idx on bridge.accounts (username_key);
|
|
14
|
+
|
|
15
|
+
-- One connection per account. A given Paperclip identity (instance + user id) can belong to only one account.
|
|
16
|
+
create table if not exists bridge.account_links (
|
|
17
|
+
account_id text primary key references bridge.accounts (id) on delete cascade,
|
|
18
|
+
instance_url text not null,
|
|
19
|
+
paperclip_user_id text,
|
|
20
|
+
sealed_credential text, -- null once dropped (idle expiry / disconnect)
|
|
21
|
+
created_at bigint not null,
|
|
22
|
+
connected_at bigint not null,
|
|
23
|
+
last_used_at bigint not null
|
|
24
|
+
);
|
|
25
|
+
create unique index if not exists account_links_identity_idx on bridge.account_links (instance_url, paperclip_user_id) where paperclip_user_id is not null;
|
|
26
|
+
create index if not exists account_links_idle_idx on bridge.account_links (last_used_at) where sealed_credential is not null;
|
|
27
|
+
|
|
28
|
+
-- The community log: joined / left / updated / login / ... (see src/accounts/types.ts). `detail` is a short non-sensitive reason.
|
|
29
|
+
create table if not exists bridge.user_events (
|
|
30
|
+
id bigint generated always as identity primary key,
|
|
31
|
+
ts bigint not null,
|
|
32
|
+
account_id text,
|
|
33
|
+
username text,
|
|
34
|
+
kind text not null,
|
|
35
|
+
detail text
|
|
36
|
+
);
|
|
37
|
+
create index if not exists user_events_ts_idx on bridge.user_events (ts desc);
|
|
38
|
+
create index if not exists user_events_kind_idx on bridge.user_events (kind, ts desc);
|
|
39
|
+
|
|
40
|
+
-- A row the app rewrites on a timer so a free-tier Supabase project always sees recent write activity.
|
|
41
|
+
create table if not exists bridge.heartbeat (
|
|
42
|
+
id integer primary key,
|
|
43
|
+
at bigint not null
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
alter table bridge.grants add column if not exists account_id text;
|
|
47
|
+
alter table bridge.grants add column if not exists username text;
|
|
48
|
+
alter table bridge.audit_events add column if not exists username text;
|
|
49
|
+
create index if not exists grants_account_idx on bridge.grants (account_id) where not revoked;
|
|
50
|
+
create index if not exists audit_username_idx on bridge.audit_events (username, ts desc) where username is not null;
|
|
51
|
+
|
|
52
|
+
alter table bridge.accounts enable row level security;
|
|
53
|
+
alter table bridge.account_links enable row level security;
|
|
54
|
+
alter table bridge.user_events enable row level security;
|
|
55
|
+
alter table bridge.heartbeat enable row level security;
|
|
56
|
+
revoke all on bridge.accounts, bridge.account_links, bridge.user_events, bridge.heartbeat from public;
|
|
57
|
+
|
|
58
|
+
do $$
|
|
59
|
+
declare r text;
|
|
60
|
+
begin
|
|
61
|
+
foreach r in array array['anon', 'authenticated'] loop
|
|
62
|
+
if exists (select 1 from pg_roles where rolname = r) then
|
|
63
|
+
execute format('revoke all on bridge.accounts, bridge.account_links, bridge.user_events, bridge.heartbeat from %I', r);
|
|
64
|
+
end if;
|
|
65
|
+
end loop;
|
|
66
|
+
end $$;
|