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 +11 -0
- package/dist/chunks/{chunk-RRSB326L.js → chunk-HN5V6BS6.js} +42 -9
- package/dist/connectors/linear.js +367 -87
- package/dist/connectors/mcp.js +87 -4
- package/dist/index.js +19 -19
- package/dist/server.js +1 -1
- package/dist/types/connectors/linear/icon.d.ts +1 -0
- package/dist/types/connectors/linear/index.d.ts +7 -4
- package/dist/types/connectors/mcp/icon.d.ts +1 -0
- package/dist/types/connectors/mcp/oauth.d.ts +13 -0
- package/dist/types/server/connector.d.ts +19 -1
- package/dist/types/server/service.d.ts +2 -0
- package/dist/types/shared/types.d.ts +55 -0
- package/dist/types/widget/api.d.ts +10 -1
- package/dist/types/widget/connectors.d.ts +13 -0
- package/dist/widget.global.js +19 -19
- package/package.json +1 -1
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
|
|
484
|
-
const act = (pick) => async (...args) => pick(actionsFor(await
|
|
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
|
-
|
|
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 });
|