Public: anyone can read this; the crew can change it.

GET /api/v1/explore answers `{ items, next_cursor }`, and MCP `explore` takes the cursor #6417

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6413-get-api-v1-explore-answers-items-next-cu → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/6413-get-api-v1-explore-answers-items-next-cu was merged into main as 58d1fb5.

Review

Sign in to review this change.
6 files changed +274 −40
docs/MCP.md +1 −1
@@ -68,7 +68,7 @@ for a tunnel and a forge that sleeps.
6868 | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes |
6969 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
7070 | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | |
71 | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | |
71 | `explore` | `{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor` | |
7272 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
7373 | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | |
7474 | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes |
src/actions/explore.ts +107 −19
@@ -189,6 +189,81 @@ export async function explore(
189189 query: ExploreQuery,
190190 now?: number,
191191 ): Promise<ExploreRow[]> {
192 return (await everything(app, viewer, query, now)).slice(0, ROWS);
193 }
194
195 export interface ExplorePage {
196 readonly items: readonly ExploreRow[];
197 /** Pass back as `cursor` for the page after; null on the last. */
198 readonly nextCursor: string | null;
199 }
200
201 /**
202 * One page after `cursor` (a `next_cursor` of the page before), or null when
203 * the cursor is not one. The cursor holds the last row's sort value, kind and
204 * name, so the next page starts after that place in the order even when the
205 * row itself has since gone.
206 */
207 export async function explorePage(
outdated

The cursor comes from the last row of the page, so any filter a caller applies afterwards (run tokens in api.ts) is out of sync with it. Take a visibility predicate here and apply it to rows first.

208 app: App,
209 viewer: User | undefined,
210 query: ExploreQuery,
211 cursor: string | null,
212 visible?: (row: ExploreRow) => boolean,
213 now?: number,
214 ): Promise<ExplorePage | null> {
215 const after = cursor === null ? undefined : readCursor(cursor, query);
216 if (after === null) return null;
217 // The caller's narrowing comes first, so a page and its cursor only ever
218 // hold rows the caller may see.
219 const all = await everything(app, viewer, query, now);
220 const rows = visible === undefined ? all : all.filter(visible);
221 const rest = after === undefined ? rows : rows.filter((r) => compare(r, after, query) > 0);
222 const items = rest.slice(0, ROWS);
223 return {
224 items,
225 nextCursor: rest.length > ROWS ? writeCursor(items[items.length - 1]!, query) : null,
226 };
227 }
228
229 interface Place {
230 readonly key: number | string;
231 readonly kind: ExploreKind;
232 readonly name: string;
233 }
234
235 const CURSOR_MAX = 400;
outdated

This only tells name apart from the numeric sorts, so a sort:score cursor is accepted under sort:forks and starts at an arbitrary place. Encode query.sort in the cursor and refuse a mismatch.

236
237 function writeCursor(row: ExploreRow, query: ExploreQuery): string {
238 const place: [ExploreSort, number | string, ExploreKind, string] = [query.sort, sortKey(row, query.sort), row.kind, row.name];
239 return Buffer.from(JSON.stringify(place)).toString("base64url");
240 }
241
242 /** The place a cursor names, undefined never; null when it is not one for this query. */
243 function readCursor(cursor: string, query: ExploreQuery): Place | null {
244 if (cursor.length > CURSOR_MAX || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null;
245 let value: unknown;
246 try {
247 value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
248 } catch {
249 return null;
250 }
251 if (!Array.isArray(value) || value.length !== 4) return null;
252 const [sort, key, kind, name] = value as unknown[];
253 if (sort !== query.sort) return null;
254 const wanted = query.sort === "name" ? "string" : "number";
255 if (typeof key !== wanted || (typeof key === "number" && !Number.isFinite(key))) return null;
256 if (kind !== "app" && kind !== "person" && kind !== "forge") return null;
257 if (typeof name !== "string" || name.length > 200) return null;
258 return { key: key as number | string, kind, name };
259 }
260
261 async function everything(
262 app: App,
263 viewer: User | undefined,
264 query: ExploreQuery,
265 now?: number,
266 ): Promise<readonly ExploreRow[]> {
192267 const forgeRows = await forges(app, viewer, query);
193268 const stamp = now === undefined ? writeStamp(app.db) : undefined;
194269 if (stamp === undefined) return place(rank(app, viewer, query, now ?? Date.now()), forgeRows, query);
@@ -349,6 +424,33 @@ function rank(
349424 );
350425 }
351426
427 function sortKey(r: ExploreRow, sort: ExploreSort): number | string {
428 switch (sort) {
429 case "score": return r.score;
430 case "forks": return r.numbers.forks;
431 case "ships": return r.numbers.ships;
432 case "use": return r.numbers.awakeHours;
433 case "fresh": return r.shippedAt === null ? 0 : Date.parse(r.shippedAt) || 0;
434 case "name": return r.name.toLowerCase();
435 }
436 }
437
438 /**
439 * The total order: the sort value, then kind and name, so no two rows tie and
440 * a cursor names exactly one place in it.
441 */
442 function compare(a: Place | ExploreRow, b: Place | ExploreRow, query: ExploreQuery): number {
443 const x = "key" in a ? a.key : sortKey(a, query.sort);
444 const y = "key" in b ? b.key : sortKey(b, query.sort);
445 const by =
446 query.sort === "name"
447 ? String(x) < String(y) ? -1 : String(x) > String(y) ? 1 : 0
448 : (y as number) - (x as number);
449 if (by !== 0) return by;
450 if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
451 return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
452 }
453
352454 /** Whether the row's `shippedAt` (its last merged pull) is inside the window. */
353455 function movedWithin(row: ExploreRow, query: ExploreQuery, now: number): boolean {
354456 if (query.updated === "all") return true;
@@ -359,33 +461,19 @@ function movedWithin(row: ExploreRow, query: ExploreQuery, now: number): boolean
359461
360462 /** Rows sorted as the query says, in place. */
361463 function order(rows: ExploreRow[], query: ExploreQuery): ExploreRow[] {
362 const by: Record<ExploreSort, (r: ExploreRow) => number | string> = {
363 score: (r) => r.score,
364 forks: (r) => r.numbers.forks,
365 ships: (r) => r.numbers.ships,
366 use: (r) => r.numbers.awakeHours,
367 fresh: (r) => (r.shippedAt === null ? 0 : Date.parse(r.shippedAt)),
368 name: (r) => r.name.toLowerCase(),
369 };
370 const key = by[query.sort];
371 return rows.sort((a, b) => {
372 const x = key(a);
373 const y = key(b);
374 if (query.sort === "name") return String(x).localeCompare(String(y));
375 return (y as number) - (x as number) || a.name.localeCompare(b.name);
376 });
464 return rows.sort((a, b) => compare(a, b, query));
377465 }
378466
379467 /**
380468 * The answer: the ranked rows (kept, so never changed here) with the forge
381 * rows among them, cut to a page. Only the forge rows lack a write clock,
469 * rows among them, whole (the callers cut a page). Only the forge rows lack a write clock,
382470 * so they are added after the kept read, to a copy.
383471 */
384 function place(ranked: readonly ExploreRow[], forgeRows: ExploreRow[], query: ExploreQuery): ExploreRow[] {
472 function place(ranked: readonly ExploreRow[], forgeRows: ExploreRow[], query: ExploreQuery): readonly ExploreRow[] {
385473 const text = query.text.toLowerCase();
386474 const own = forgeRows.filter(
387475 (row) => text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text),
388476 );
389 if (own.length === 0) return ranked.slice(0, ROWS);
390 return order([...ranked, ...own], query).slice(0, ROWS);
477 if (own.length === 0) return ranked;
478 return order([...ranked, ...own], query);
391479 }
src/contract/openapi/routes/getApiV1Explore.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } };
3 export const route: Route = { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items, next_cursor } (60 rows a page, next_cursor null on the last); cursor from next_cursor; a bad cursor is a 400", auth: false, query: { q: "string", cursor: "string" } };
src/http/api.ts +16 −13
@@ -450,7 +450,8 @@ import {
450450 type AppMembersError,
451451 } from "../actions/app_members.ts";
452452 import {
453 explore,
453 explorePage,
454 type ExploreRow,
454455 exploreLine,
455456 parseExploreQuery,
456457 } from "../actions/explore.ts";
@@ -5385,19 +5386,21 @@ export function apiRoutes(router: Router<Handler>): void {
53855386 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
53865387 const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? "");
53875388 const mine = runRepos(ctx.app, ctx.auth);
5388 const all = await timePhaseAsync(ctx.request, "explore", () =>
5389 explore(ctx.app, ctx.auth.user, query),
5390 );
5391 // A run token is shown only the rows of its own repositories.
5392 const items =
5389 // A run token is shown only the rows of its own repositories; the cut
5390 // and the cursor are made from those rows alone.
5391 const visible =
53935392 mine === undefined
5394 ? all
5395 : all.filter(
5396 (row) =>
5397 row.repo !== undefined &&
5398 mine.some((r) => r.ownerName.toLowerCase() === row.repo!.owner.toLowerCase() && r.name.toLowerCase() === row.repo!.name.toLowerCase()),
5399 );
5400 return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items }));
5393 ? undefined
outdated

High: page.nextCursor was written from the last row before the run-token filter above, so it encodes [score, kind, name] of a row this token may not see. Filter inside explorePage before slicing and writing the cursor.

5394 : (row: ExploreRow) =>
5395 row.repo !== undefined &&
5396 mine.some((r) => r.ownerName.toLowerCase() === row.repo!.owner.toLowerCase() && r.name.toLowerCase() === row.repo!.name.toLowerCase());
5397 const page = await timePhaseAsync(ctx.request, "explore", () =>
5398 explorePage(ctx.app, ctx.auth.user, query, ctx.url.searchParams.get("cursor"), visible),
5399 );
5400 if (page === null)
5401 return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before");
5402 const items = page.items;
5403 return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items, next_cursor: page.nextCursor }));
54015404 });
54025405
54035406 // Make it yours (#606): a fork with its app, planned unless dryRun is
src/http/mcp/tools/explore.ts +12 −5
@@ -1,11 +1,11 @@
1 import { explore, exploreLine, parseExploreQuery } from "../../../actions/explore.ts";
2 import { good, type Tool, str } from "../shared.ts";
1 import { explorePage, exploreLine, parseExploreQuery } from "../../../actions/explore.ts";
2 import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "explore",
6 doc: "`{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line",
6 doc: "`{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor`",
77 description:
8 "The apps, people (and for a site admin, spawned forges) you can see, ranked.",
8 "The apps, people (and, for an admin, forges) you can see, ranked.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
@@ -14,14 +14,21 @@ export const tool: Tool = {
1414 maxLength: 200,
1515 description: "Words; kind: sort: updated:7d",
1616 },
17 cursor: {
18 type: "string",
19 },
1720 },
1821 },
1922 writes: false,
2023 async run(ctx, user, args) {
2124 const query = parseExploreQuery(str(args["query"]));
25 const cursor = str(args["cursor"]);
26 const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor);
27 if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before");
2228 return good({
2329 query: exploreLine(query),
24 items: await explore(ctx.app, user, query),
30 items: page.items,
31 next_cursor: page.nextCursor,
2532 });
2633 },
2734 };
test/explore.test.ts +137 −1
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { exploreLine, parseExploreQuery } from "../src/actions/explore.ts";
12 import { explorePage, exploreLine, parseExploreQuery } from "../src/actions/explore.ts";
1313 import { suspendAccount } from "../src/actions/accounts.ts";
1414 import { createRepo } from "../src/actions/repos.ts";
1515 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -450,3 +450,139 @@ describe("explore on a forge with nothing public", () => {
450450 expect(out).toContain("Nothing matches &lt;script&gt;x&lt;/script&gt;");
451451 });
452452 });
453
454 describe("explore past the first page", () => {
455 let dataDir: string;
456 let app: App;
457 let servers: Servers;
458 let token: string;
459 const COUNT = 70;
460
461 const get = async (q: string, cursor?: string) =>
462 fetch(
463 `${servers.url}/api/v1/explore?q=${encodeURIComponent(q)}${cursor === undefined ? "" : `&cursor=${encodeURIComponent(cursor)}`}`,
464 );
465
466 beforeAll(async () => {
467 dataDir = await mkdtemp(join(tmpdir(), "forge-explore-pages-"));
468 app = createApp({ dataDir, port: 0 });
469 servers = await serve(app);
470 const made = await createUser(app.db, {
471 origin: "seed",
472 username: "paged",
473 email: "[email protected]",
474 password: "a-good-passphrase",
475 adminIfFirst: true,
476 });
477 if (!made.ok) throw new Error(made.error);
478 proveAll(app);
479 promote(app.db, made.value);
480 token = createToken(app.db, made.value.id, "ro", "ro").token;
481 for (let i = 0; i < COUNT; i++) {
482 const name = `r${String(i).padStart(3, "0")}`;
483 const repo = await createRepo(app, made.value, { name, visibility: "public", description: name });
484 if (!repo.ok) throw new Error(String(repo.error));
485 const record = insertApp(app.db, { repoId: repo.value.id, slug: `paged-${name}`, access: "public" });
486 if (!record.ok) throw new Error(record.error);
487 }
488 }, 60_000);
489
490 afterAll(async () => {
491 servers.stop();
492 app.close();
493 await rm(dataDir, { recursive: true, force: true });
494 });
495
496 for (const sort of ["score", "name"]) {
497 test(`following next_cursor reaches every app once, in rank order (sort:${sort})`, async () => {
498 const q = `kind:app sort:${sort}`;
499 const first = (await (await get(q)).json()) as any;
500 expect(first.items.length).toBe(60);
501 expect(typeof first.next_cursor).toBe("string");
502 const seen: string[] = first.items.map((r: any) => r.name);
503 let cursor: string | null = first.next_cursor;
504 let pages = 1;
505 while (cursor !== null) {
506 const page = (await (await get(q, cursor)).json()) as any;
507 seen.push(...page.items.map((r: any) => r.name));
508 cursor = page.next_cursor;
509 pages++;
510 }
511 expect(pages).toBe(2);
512 expect(seen.length).toBe(COUNT);
513 expect(new Set(seen).size).toBe(COUNT);
514 expect(seen).toEqual([...seen].sort());
515 });
516 }
517
518 test("the last page and a short list answer next_cursor null", async () => {
519 const short = (await (await get("r001")).json()) as any;
520 expect(short.next_cursor).toBeNull();
521 });
522
523 test("a malformed cursor, or one from another sort, is refused", async () => {
524 for (const bad of ["!!", "x".repeat(500), Buffer.from("[1,2]").toString("base64url")]) {
525 const res = await get("kind:app", bad);
526 expect(res.status).toBe(400);
527 expect(((await res.json()) as any).error).toBe("invalid_cursor");
528 }
529 const first = (await (await get("kind:app")).json()) as any;
530 expect((await get("kind:app sort:name", first.next_cursor)).status).toBe(400);
531 });
532
533 test("MCP explore takes the cursor and answers next_cursor", async () => {
534 const call = async (args: object) => {
535 const res = await fetch(`${servers.url}/mcp`, {
536 method: "POST",
537 headers: {
538 "content-type": "application/json",
539 accept: "application/json, text/event-stream",
540 authorization: `Bearer ${token}`,
541 },
542 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: args } }),
543 });
544 return (await res.json()) as any;
545 };
546 const first = JSON.parse((await call({ query: "kind:app" })).result.content[0].text);
547 expect(first.items.length).toBe(60);
548 const second = JSON.parse((await call({ query: "kind:app", cursor: first.next_cursor })).result.content[0].text);
549 expect(second.items.length).toBe(COUNT - 60);
550 expect(second.next_cursor).toBeNull();
551 const refused = await call({ query: "kind:app", cursor: "!!" });
552 expect(JSON.stringify(refused)).toContain("invalid_cursor");
553 });
554
555 test("a cursor made under one sort is refused under every other", async () => {
556 const sorts = ["score", "forks", "ships", "use", "fresh", "name"];
557 for (const made of sorts) {
558 const first = (await (await get(`kind:app sort:${made}`)).json()) as any;
559 for (const used of sorts) {
560 const res = await get(`kind:app sort:${used}`, first.next_cursor);
561 expect(res.status).toBe(made === used ? 200 : 400);
562 }
563 }
564 });
565
566 test("a narrowed caller's pages and cursors hold only rows it may see", async () => {
567 const viewer = undefined;
568 const query = parseExploreQuery("kind:app sort:name");
569 // Hides the row each unnarrowed page would end on, and every tenth after.
570 const visible = (row: { name: string }) => !/[09]$/.test(row.name) || row.name.endsWith("r000");
571 const seen: string[] = [];
572 let cursor: string | null = null;
573 do {
574 const page: Awaited<ReturnType<typeof explorePage>> = await explorePage(app, viewer, query, cursor, visible);
575 if (page === null) throw new Error("refused");
576 seen.push(...page.items.map((r) => r.name));
577 if (page.nextCursor !== null) {
578 const [, , , name] = JSON.parse(Buffer.from(page.nextCursor, "base64url").toString());
579 expect(visible({ name })).toBe(true);
580 expect(page.items.length).toBe(60);
581 }
582 cursor = page.nextCursor;
583 } while (cursor !== null);
584 expect(seen.every((n) => visible({ name: n }))).toBe(true);
585 expect(new Set(seen).size).toBe(seen.length);
586 expect(seen.length).toBe(COUNT - 14 + 1);
587 });
588 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs