web-comments 0.1.0 → 1.0.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/README.md CHANGED
@@ -4,11 +4,12 @@ Figma-style comments for any web page. Press <kbd>C</kbd>, click an element, wri
4
4
 
5
5
  Every comment thread records the element (selector, text, HTML, position), the URL, a screenshot of the element and one of the page as the commenter saw it.
6
6
 
7
- Three parts are open interfaces, so it fits whatever you already run:
7
+ Four parts are open interfaces, so it fits whatever you already run:
8
8
 
9
9
  - **Sign-in**: auth plugins answer "who is this?" from what already sits in front of your app: your own session endpoint, Cloudflare Access, a bearer token, or anything you write. The server never stores passwords or sessions.
10
10
  - **Database**: a `CommentsStore`. Built in: Drizzle over SQLite (D1, libSQL/Turso), Postgres (PlanetScale Postgres, Neon, Supabase, ...) and MySQL (PlanetScale, ...), plus an in-memory store. Or implement the interface over anything.
11
11
  - **Screenshots**: an `AssetStore`. Built in: R2 and S3 (and S3-compatible), plus in-memory. Or implement `put`/`get` over any storage or HTTP API.
12
+ - **Connectors**: where comments go and what can act on them. Built in: **Linear** (every thread an issue, kept in step both ways) and **MCP** (agents read threads with their screenshots, reply and resolve, signed in with OAuth as the person who approved them). Or write your own.
12
13
 
13
14
  The **widget** is a toolbar docked on the right of the page, built with [React Spectrum 2](https://react-spectrum.adobe.com/) and kept in a shadow root, so neither your CSS nor Spectrum's leaks across. Only people the API accepts see it; for everyone else the page is untouched.
14
15
 
@@ -56,6 +57,8 @@ Mount it on the same origin as the pages being commented on, so the browser send
56
57
  | `allowedOrigins` | Other origins that may call the API with credentials. |
57
58
  | `widgetScript` | The contents of `dist/widget.global.js`, served at `{basePath}/widget.js`. |
58
59
  | `maxScreenshotBytes` | Default 5 MB per screenshot. |
60
+ | `linkParam` | Query parameter of thread links, as the widget's. Default `comment`. |
61
+ | `connectors` | Integrations: `linear(...)`, `mcp(...)`, or your own. See [Connectors](#connectors). |
59
62
  | `onEvent` | Called after every change (`thread.created`, `comment.created`, `thread.resolved`, `thread.moved`, ...), for integrations. `thread.created` already carries the screenshots. |
60
63
 
61
64
  ### Sign-in
@@ -130,6 +133,78 @@ assets: (env) =>
130
133
 
131
134
  Screenshots are drawn from the DOM in the browser (no screen-capture prompt). Cross-origin images that don't allow CORS can't be drawn and come out blank; a comment never waits more than a few seconds for its screenshots.
132
135
 
136
+ ## Connectors
137
+
138
+ ```ts
139
+ import { linear } from "web-comments/connectors/linear";
140
+ import { mcp } from "web-comments/connectors/mcp";
141
+
142
+ connectors: [
143
+ mcp(),
144
+ linear((env) => ({ auth: { apiKey: env.LINEAR_API_KEY }, team: "ENG", webhookSecret: env.LINEAR_WEBHOOK_SECRET })),
145
+ ],
146
+ ```
147
+
148
+ Connectors need a store that keeps links and values (`saveLink`, `findLink`, `listLinks`, `getValue`, `setValue`, `deleteValue`); the built-in stores do, and create the two extra tables (`wc_links`, `wc_values`) on first use.
149
+
150
+ ### MCP
151
+
152
+ `mcp()` serves an [MCP](https://modelcontextprotocol.io) server at `{basePath}/mcp`:
153
+
154
+ ```sh
155
+ claude mcp add --transport http comments https://app.example.com/__comments/mcp
156
+ ```
157
+
158
+ | Tool | |
159
+ | --- | --- |
160
+ | `list_comment_threads` | Open threads (or resolved, or all), on one page or the whole site: what was said, where, by whom. |
161
+ | `get_comment_thread` | One thread in full: every comment, the element's selector, HTML and box, and both screenshots as images. |
162
+ | `reply_to_comment_thread` | Replies, as the agent. |
163
+ | `resolve_comment_thread` | Resolves, optionally replying first with what was done. |
164
+ | `reopen_comment_thread` | Opens a resolved thread again. |
165
+
166
+ MCP clients sign in with OAuth 2.1, as the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) describes: protected resource and authorization server metadata, dynamic client registration and client ID metadata documents, authorization codes with PKCE, rotating refresh tokens. The person approving a client is whoever your auth plugins already recognise in their browser, so it works with whatever sign-in you plugged in; someone not signed in is sent to the plugins' `loginUrl`. The agent then acts as that person, under their name with the client's in brackets ("Ada (Claude Code)"). Tokens are opaque and kept hashed in the store.
167
+
168
+ The metadata is served under `{basePath}/mcp`, which is all a host has to route. Hosts that also send `/.well-known/oauth-protected-resource{basePath}/mcp` and its siblings (`matches()` says yes to them) serve them at the origin's root too. Requests your auth plugins recognise directly, such as a `bearerToken` for a CI agent, are let in without OAuth.
169
+
170
+ | Option | |
171
+ | --- | --- |
172
+ | `name` | What the server calls itself to clients and on the approval page. Default `web-comments`. |
173
+ | `accessTokenTtl` | Seconds. Default an hour. |
174
+ | `refreshTokenTtl` | Seconds. Default 30 days. |
175
+
176
+ ### Linear
177
+
178
+ `linear(options)`: every thread becomes an issue in a team, with the comment, a link back to the thread, the element, and both screenshots uploaded to Linear. Replies become issue comments; resolving the thread completes the issue (the team's first "completed" state) and reopening it moves the issue back (its first "unstarted" state). With a webhook, Linear's side comes back too: comments there become replies, and completing or cancelling the issue resolves the thread. Threads show their issue ("Linear ENG-123") in the widget.
179
+
180
+ | Option | |
181
+ | --- | --- |
182
+ | `auth` | `{ apiKey }` (a personal key: the issues are that person's, and say who commented), `{ accessToken }` (OAuth), or `{ clientId, clientSecret }` (an OAuth app with client credentials: the issues are the app's, under each commenter's name and picture). |
183
+ | `team` | The team's key (`ENG`) or id. |
184
+ | `webhookSecret` | Signing secret of a Linear webhook for Issues and Comments, pointed at `{basePath}/linear/webhook`. |
185
+ | `labelIds`, `projectId` | Where new issues go. |
186
+ | `filter` | `(thread) => boolean`: which threads become issues. |
187
+
188
+ `options` can be a function of the environment; returning `null` leaves Linear out there (say, until its secrets are set).
189
+
190
+ ### Your own
191
+
192
+ ```ts
193
+ interface Connector<Env> {
194
+ name: string; // lowercase; its routes live under {basePath}/{name}
195
+ onEvent?(event: CommentsEvent, context: ConnectorContext<Env>): void | Promise<void>;
196
+ fetch?(request: Request, context: ConnectorRequestContext<Env>): Response | Promise<Response>;
197
+ rootPaths?(basePath: string): string[];
198
+ }
199
+ ```
200
+
201
+ - `onEvent` hears every change once the response has gone out (`thread.created`, `comment.created`, `thread.resolved`, ...), except the ones it made itself.
202
+ - `fetch` answers requests under `{basePath}/{name}`, e.g. a webhook. Nobody is signed in for them: verify the caller yourself, or ask `context.identify(request)` for the person the API would see.
203
+ - `context.as(person)` acts on comments by the API's own rules (`listThreads`, `getThread`, `reply`, `setStatus`, `deleteComment`). Other connectors and `onEvent` hear about it, with the connector as the event's `source`.
204
+ - `context.links` ties threads and comments to their counterparts elsewhere (`save`, `find(externalId)`, `forThread`); links with a URL show on the thread.
205
+ - `context.storage` keeps the connector's own state (`get`, `set` with an optional TTL, `delete`), apart from other connectors'.
206
+ - `context.screenshot(thread, kind)` reads a screenshot from the asset store; `context.threadUrl(thread)` is the page with the thread open.
207
+
133
208
  ## Widget
134
209
 
135
210
  The toolbar sits along the right edge: comment mode (<kbd>C</kbd>), this page's comments, and who you're signed in as. By default the page is moved over to make room for it (`margin-right` on `<html>`), so nothing is covered; `dock: "overlay"` draws it on top instead. The comments panel floats beside it. If the API answers 401 or 403, nothing is shown and the page is left alone.
@@ -187,7 +262,7 @@ pnpm dev:demo # 127.0.0.1:$PORT (default 8795), wit
187
262
 
188
263
  ```sh
189
264
  pnpm install
190
- pnpm test # the server against the memory, D1 (Miniflare) and Postgres (PGlite) stores; auth; assets; anchoring
265
+ pnpm test # the server and connectors against the memory, D1 (Miniflare) and Postgres (PGlite) stores; MCP with the official SDK client; Linear against a stand-in API; auth; assets; anchoring
191
266
  pnpm typecheck
192
267
  pnpm check # biome
193
268
  pnpm build
@@ -7,10 +7,12 @@ var sqlite_exports = {};
7
7
  __export(sqlite_exports, {
8
8
  comments: () => comments,
9
9
  createTablesSql: () => createTablesSql,
10
+ links: () => links,
10
11
  threads: () => threads,
11
- users: () => users
12
+ users: () => users,
13
+ values: () => values
12
14
  });
13
- import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
15
+ import { index, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
14
16
  var threads = sqliteTable(
15
17
  "wc_threads",
16
18
  {
@@ -55,18 +57,45 @@ var users = sqliteTable("wc_users", {
55
57
  kind: text("kind").notNull(),
56
58
  lastSeenAt: text("last_seen_at").notNull()
57
59
  });
60
+ var links = sqliteTable(
61
+ "wc_links",
62
+ {
63
+ site: text("site").notNull(),
64
+ connector: text("connector").notNull(),
65
+ externalId: text("external_id").notNull(),
66
+ threadId: text("thread_id").notNull(),
67
+ commentId: text("comment_id"),
68
+ url: text("url"),
69
+ title: text("title"),
70
+ createdAt: text("created_at").notNull()
71
+ },
72
+ (t) => [
73
+ primaryKey({ columns: [t.site, t.connector, t.externalId] }),
74
+ index("wc_links_thread").on(t.site, t.threadId)
75
+ ]
76
+ );
77
+ var values = sqliteTable("wc_values", {
78
+ name: text("name").primaryKey(),
79
+ value: text("value").notNull(),
80
+ expiresAt: text("expires_at")
81
+ });
58
82
  var createTablesSql = [
59
83
  `CREATE TABLE IF NOT EXISTS wc_threads (id TEXT PRIMARY KEY, site TEXT NOT NULL, page TEXT NOT NULL, url TEXT NOT NULL, anchor TEXT NOT NULL, context TEXT, screenshots TEXT NOT NULL, status TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, resolved_at TEXT, resolved_by TEXT)`,
60
84
  `CREATE INDEX IF NOT EXISTS wc_threads_page ON wc_threads (site, page, created_at)`,
61
85
  `CREATE TABLE IF NOT EXISTS wc_comments (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, body TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL)`,
62
86
  `CREATE INDEX IF NOT EXISTS wc_comments_thread ON wc_comments (thread_id, created_at)`,
63
- `CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`
87
+ `CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`,
88
+ `CREATE TABLE IF NOT EXISTS wc_links (site TEXT NOT NULL, connector TEXT NOT NULL, external_id TEXT NOT NULL, thread_id TEXT NOT NULL, comment_id TEXT, url TEXT, title TEXT, created_at TEXT NOT NULL, PRIMARY KEY (site, connector, external_id))`,
89
+ `CREATE INDEX IF NOT EXISTS wc_links_thread ON wc_links (site, thread_id)`,
90
+ `CREATE TABLE IF NOT EXISTS wc_values (name TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at TEXT)`
64
91
  ];
65
92
 
66
93
  export {
67
94
  threads,
68
95
  comments,
69
96
  users,
97
+ links,
98
+ values,
70
99
  createTablesSql,
71
100
  sqlite_exports
72
101
  };
@@ -7,10 +7,12 @@ var pg_exports = {};
7
7
  __export(pg_exports, {
8
8
  comments: () => comments,
9
9
  createTablesSql: () => createTablesSql,
10
+ links: () => links,
10
11
  threads: () => threads,
11
- users: () => users
12
+ users: () => users,
13
+ values: () => values
12
14
  });
13
- import { index, pgTable, text } from "drizzle-orm/pg-core";
15
+ import { index, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
14
16
  var threads = pgTable(
15
17
  "wc_threads",
16
18
  {
@@ -55,18 +57,45 @@ var users = pgTable("wc_users", {
55
57
  kind: text("kind").notNull(),
56
58
  lastSeenAt: text("last_seen_at").notNull()
57
59
  });
60
+ var links = pgTable(
61
+ "wc_links",
62
+ {
63
+ site: text("site").notNull(),
64
+ connector: text("connector").notNull(),
65
+ externalId: text("external_id").notNull(),
66
+ threadId: text("thread_id").notNull(),
67
+ commentId: text("comment_id"),
68
+ url: text("url"),
69
+ title: text("title"),
70
+ createdAt: text("created_at").notNull()
71
+ },
72
+ (t) => [
73
+ primaryKey({ columns: [t.site, t.connector, t.externalId] }),
74
+ index("wc_links_thread").on(t.site, t.threadId)
75
+ ]
76
+ );
77
+ var values = pgTable("wc_values", {
78
+ name: text("name").primaryKey(),
79
+ value: text("value").notNull(),
80
+ expiresAt: text("expires_at")
81
+ });
58
82
  var createTablesSql = [
59
83
  `CREATE TABLE IF NOT EXISTS wc_threads (id TEXT PRIMARY KEY, site TEXT NOT NULL, page TEXT NOT NULL, url TEXT NOT NULL, anchor TEXT NOT NULL, context TEXT, screenshots TEXT NOT NULL, status TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, resolved_at TEXT, resolved_by TEXT)`,
60
84
  `CREATE INDEX IF NOT EXISTS wc_threads_page ON wc_threads (site, page, created_at)`,
61
85
  `CREATE TABLE IF NOT EXISTS wc_comments (id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, body TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, author_avatar TEXT, author_kind TEXT NOT NULL, created_at TEXT NOT NULL)`,
62
86
  `CREATE INDEX IF NOT EXISTS wc_comments_thread ON wc_comments (thread_id, created_at)`,
63
- `CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`
87
+ `CREATE TABLE IF NOT EXISTS wc_users (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT, avatar_url TEXT, kind TEXT NOT NULL, last_seen_at TEXT NOT NULL)`,
88
+ `CREATE TABLE IF NOT EXISTS wc_links (site TEXT NOT NULL, connector TEXT NOT NULL, external_id TEXT NOT NULL, thread_id TEXT NOT NULL, comment_id TEXT, url TEXT, title TEXT, created_at TEXT NOT NULL, PRIMARY KEY (site, connector, external_id))`,
89
+ `CREATE INDEX IF NOT EXISTS wc_links_thread ON wc_links (site, thread_id)`,
90
+ `CREATE TABLE IF NOT EXISTS wc_values (name TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at TEXT)`
64
91
  ];
65
92
 
66
93
  export {
67
94
  threads,
68
95
  comments,
69
96
  users,
97
+ links,
98
+ values,
70
99
  createTablesSql,
71
100
  pg_exports
72
101
  };