web-comments 1.0.1 → 1.0.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.
package/README.md CHANGED
@@ -165,6 +165,8 @@ claude mcp add --transport http comments https://app.example.com/__comments/mcp
165
165
 
166
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
167
 
168
+ `authorize` is asked again on every MCP request, so someone who may no longer use comments takes their agents with them. Each person's agents are listed in the widget's MCP panel, with the server's address and the `claude mcp add` line to copy; disconnecting one there ends all its tokens at once.
169
+
168
170
  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
171
 
170
172
  | Option | |
@@ -177,6 +179,12 @@ The metadata is served under `{basePath}/mcp`, which is all a host has to route.
177
179
 
178
180
  `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
181
 
182
+ People are matched across the two:
183
+
184
+ - **Connected**: with an OAuth app (`auth: { clientId, clientSecret }`, callback URL `{basePath}/linear/callback`), anyone can connect their own Linear account from the widget's Linear panel. What they do here then reaches Linear as them, and what they do in Linear comes back as them. They can disconnect there too, which revokes the token.
185
+ - **Same email**: someone whose email is a Linear user's follows the issues they open, and their Linear comments come back as them; their comments still reach Linear under the app's name.
186
+ - **Neither**: their Linear button carries a dot, and the panel asks them to connect.
187
+
180
188
  | Option | |
181
189
  | --- | --- |
182
190
  | `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). |
@@ -195,6 +203,8 @@ interface Connector<Env> {
195
203
  onEvent?(event: CommentsEvent, context: ConnectorContext<Env>): void | Promise<void>;
196
204
  fetch?(request: Request, context: ConnectorRequestContext<Env>): Response | Promise<Response>;
197
205
  rootPaths?(basePath: string): string[];
206
+ panel?(who: Identity, context: ConnectorContext<Env>): Promise<ConnectorPanel | null>;
207
+ action?(action: string, who: Identity, input: Record<string, unknown>, context: ConnectorContext<Env>): Promise<void>;
198
208
  }
199
209
  ```
200
210
 
@@ -204,6 +214,7 @@ interface Connector<Env> {
204
214
  - `context.links` ties threads and comments to their counterparts elsewhere (`save`, `find(externalId)`, `forThread`); links with a URL show on the thread.
205
215
  - `context.storage` keeps the connector's own state (`get`, `set` with an optional TTL, `delete`), apart from other connectors'.
206
216
  - `context.screenshot(thread, kind)` reads a screenshot from the asset store; `context.threadUrl(thread)` is the page with the thread open.
217
+ - `panel` gives the connector a button on the widget's toolbar, stacked upwards from the avatar, and a panel of its own: a title, a monochrome icon (an image URL, painted in the toolbar's icon colour), a dot when it wants attention, and blocks the widget draws (`text`, `status`, `code` to copy, `link`, `button`, `list`), so a connector needs no front-end code. A `button` calls `action`, signed in and checked like the API's own requests, and the panel is fetched again.
207
218
 
208
219
  ## Widget
209
220
 
@@ -423,10 +423,11 @@ function createComments(options) {
423
423
  return void 0;
424
424
  }
425
425
  }
426
- async function scopeFor(env, site, ctx, source) {
426
+ async function scopeFor(env, site, ctx, origin, source) {
427
427
  const store = await storeFor(env);
428
428
  const scope = {
429
429
  site,
430
+ origin,
430
431
  store,
431
432
  assets: options.assets?.(env) ?? null,
432
433
  waitUntil: (work) => keepAlive(ctx, work),
@@ -477,11 +478,12 @@ function createComments(options) {
477
478
  return {
478
479
  env,
479
480
  site,
481
+ origin: scope.origin,
480
482
  basePath,
481
483
  as(actor) {
482
- own ??= scopeFor(env, site, ctx, connector.name);
483
- const scope2 = own;
484
- const act = (pick) => async (...args) => pick(actionsFor(await scope2, actor))(...args);
484
+ own ??= scopeFor(env, site, ctx, scope.origin, connector.name);
485
+ const mine = own;
486
+ const act = (pick) => async (...args) => pick(actionsFor(await mine, actor))(...args);
485
487
  return {
486
488
  listThreads: act((a) => a.listThreads),
487
489
  getThread: act((a) => a.getThread),
@@ -527,11 +529,12 @@ function createComments(options) {
527
529
  waitUntil: (work) => keepAlive(ctx, work)
528
530
  };
529
531
  }
532
+ async function allowed(who, request, env) {
533
+ return !options.authorize || await options.authorize(who, request, env);
534
+ }
530
535
  async function identifyAllowed(request, env) {
531
536
  const who = await identify(options.auth, request, env);
532
- if (!who) return null;
533
- if (options.authorize && !await options.authorize(who, request, env)) return null;
534
- return who;
537
+ return who && await allowed(who, request, env) ? who : null;
535
538
  }
536
539
  function fail(status, code, message) {
537
540
  return Response.json({ error: { code, message } }, { status });
@@ -541,11 +544,13 @@ function createComments(options) {
541
544
  if (!connector.fetch) return fail(404, "not_found", "no such route");
542
545
  const request = c.req.raw;
543
546
  const ctx = executionOf(c);
544
- const scope = await scopeFor(c.env, siteOf(request, c.env), ctx);
547
+ const scope = await scopeFor(c.env, siteOf(request, c.env), ctx, new URL(request.url).origin);
545
548
  const context2 = {
546
549
  ...contextFor(connector, c.env, scope, ctx),
547
550
  url: `${new URL(request.url).origin}${basePath}/${connector.name}`,
548
551
  identify: (other) => identifyAllowed(other, c.env),
552
+ signedIn: (other) => identify(options.auth, other, c.env),
553
+ allowed: (who, other) => allowed(who, other, c.env),
549
554
  loginUrl: (other) => loginUrlFor(options.auth, other)
550
555
  };
551
556
  return connector.fetch(request, context2);
@@ -604,7 +609,7 @@ function createComments(options) {
604
609
  return fail(403, "forbidden", `${who.email ?? who.name} may not use comments here`);
605
610
  }
606
611
  const ctx = executionOf(c);
607
- const scope = await scopeFor(c.env, siteOf(c.req.raw, c.env), ctx);
612
+ const scope = await scopeFor(c.env, siteOf(c.req.raw, c.env), ctx, new URL(c.req.url).origin);
608
613
  const seen = Date.now();
609
614
  if (scope.store.saveUser && seen - (lastSeen.get(who.id) ?? 0) > SEEN_EVERY_MS) {
610
615
  lastSeen.set(who.id, seen);
@@ -629,6 +634,34 @@ function createComments(options) {
629
634
  return fail(500, "internal", "something went wrong");
630
635
  });
631
636
  api.get("/me", (c) => c.json({ me: c.var.who }));
637
+ api.get("/connectors", async (c) => {
638
+ const panels = await Promise.all(
639
+ connectors.filter((connector) => connector.panel).map(async (connector) => {
640
+ const context2 = contextFor(connector, c.env, c.var.scope, executionOf(c));
641
+ const panel = await Promise.resolve(connector.panel?.(c.var.who, context2)).catch(
642
+ (error) => {
643
+ console.error("web-comments:", error);
644
+ return null;
645
+ }
646
+ );
647
+ return panel && { connector: connector.name, ...panel };
648
+ })
649
+ );
650
+ return c.json({ connectors: panels.filter(Boolean) });
651
+ });
652
+ api.post("/connectors/:connector/actions/:action", async (c) => {
653
+ const connector = connectors.find((one) => one.name === c.req.param("connector"));
654
+ if (!connector?.action) return fail(404, "not_found", "no such action");
655
+ const input = c.req.header("content-type")?.includes("application/json") ? await readJson(c.req.raw) : {};
656
+ const context2 = contextFor(connector, c.env, c.var.scope, executionOf(c));
657
+ await connector.action(
658
+ c.req.param("action"),
659
+ c.var.who,
660
+ input && typeof input === "object" ? input : {},
661
+ context2
662
+ );
663
+ return c.json({ ok: true });
664
+ });
632
665
  api.get("/threads", async (c) => {
633
666
  const page = c.req.query("page");
634
667
  const threads = await service.list(c.var.scope, page === void 0 ? {} : { page });