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

The caller's SSH keys, pins and organizations answer `{ items, next_cursor }` #5373

Merged opened this

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

Merged

scout/forge:scout/5043-the-caller-s-ssh-keys-pins-and-organizat → main 10 commits fork 1 thread · 1 unresolved

  • Merged. scout/5043-the-caller-s-ssh-keys-pins-and-organizat was merged into main as b7ee348.

Review

Sign in to review this change.
12 files changed +266 −31
docs/ORIGINS.md +1 −1
@@ -607,7 +607,7 @@ POST /api/v1/repos/:o/:n/sync sync this mirror now; on a fork,
607607 POST /api/v1/repos/:o/:n/origin/reconnect {originId} → repo JSON; re-attach a
608608 detached mirror (admin token, repo
609609 admin, own origin; else 404/422 code)
610 GET /api/v1/user/pins PUT/DELETE /api/v1/user/pins/:owner/:name
610 GET /api/v1/user/pins ({items, next_cursor}; ?limit=1..200, ?cursor=) PUT/DELETE /api/v1/user/pins/:owner/:name
611611
612612 POST /api/v1/migrate {source: {url} | {originId, fullName},
613613 owner?, name?, visibility?,
src/cli/api.ts +23 −4
@@ -111,10 +111,29 @@ export class ForgeApi {
111111 * pin, so the client is useful against an older workbench.
112112 */
113113 async pins(): Promise<ApiResult<Pin[]>> {
114 const out = await this.call<unknown>("/api/v1/user/pins");
115 if (out.ok) return ok(this.toPins(out.value));
116 if (out.error.status !== 404 && out.error.status !== 501) return out;
117 this.pinsUnavailable = true;
114 const pins: Pin[] = [];
115 let cursor: string | undefined;
116 for (let page = 0; page < 50; page++) {
117 const query = cursor === undefined ? "" : `?cursor=${cursor}`;
118 const out = await this.call<unknown>(`/api/v1/user/pins${query}`);
119 if (!out.ok) {
120 if (out.error.status !== 404 && out.error.status !== 501) return out;
121 this.pinsUnavailable = true;
122 return this.ownedRepos();
123 }
124 // An older forge answers the whole list as a bare array.
125 if (Array.isArray(out.value)) return ok(this.toPins(out.value));
126 const body = (out.value ?? {}) as { items?: unknown; next_cursor?: unknown };
127 pins.push(...this.toPins(body.items));
128 if (typeof body.next_cursor !== "string" || body.next_cursor === "")
129 return ok(pins);
130 cursor = encodeURIComponent(body.next_cursor);
131 }
132 return ok(pins);

Minor: once the client has fetched 50 pages, it returns a partial list without saying so. 50 pages at the default limit of 50 is 2,500 pins, so this is unlikely to happen. Passing ?limit=200 would make the cap 10,000 and save round trips.

133 }
134
135 /** The fallback for a forge with no pins endpoint: every repo you own. */
136 private async ownedRepos(): Promise<ApiResult<Pin[]>> {
118137 const me = await this.whoami();
119138 if (!me.ok) return me;
120139 const repos = await this.repos();
src/http/api.ts +13 −4
@@ -1045,6 +1045,9 @@ function listQuery(ctx: Ctx) {
10451045 };
10461046 }
10471047
1048 /** Room for the longest key: a pin's `owner/name` (up to ~40 + 1 + 100 characters). */
1049 const KEYED_CURSOR_MAX = 160;
1050
10481051 function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response {
10491052 return error === "invalid_limit"
10501053 ? apiError(error, 422, `limit is a whole number from 1 to ${OFFSET_LIMIT_MAX}`)
@@ -1062,7 +1065,7 @@ function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null } | Respo
10621065 if (limit < 1 || limit > LIST_LIMIT_MAX)
10631066 return apiError("invalid_limit", 422, `limit is a whole number from 1 to ${LIST_LIMIT_MAX}`);
10641067 const cursor = q.get("cursor");
1065 if (cursor !== null && (cursor.length < 1 || cursor.length > 100))
1068 if (cursor !== null && (cursor.length < 1 || cursor.length > KEYED_CURSOR_MAX))
10661069 return apiError("invalid_cursor", 422, "cursor is the next_cursor of a previous page");
10671070 return { limit, cursor };
10681071 }
@@ -1995,10 +1998,12 @@ export function apiRoutes(router: Router<Handler>): void {
19951998 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
19961999 if (!ctx.auth.user) return unauthorized();
19972000 const user = ctx.auth.user;
2001 const window = keyedWindow(ctx);
2002 if (window instanceof Response) return window;
19982003 const orgs = timePhase(ctx.request, "orgs", () =>
19992004 listOrgsForUser(ctx.app.db, user.id).map(publicProfile),
20002005 );
2001 return timePhase(ctx.request, "render", () => json(orgs));
2006 return timePhase(ctx.request, "render", () => json(keyedPage(orgs, (o) => o.username, window)));
20022007 });
20032008
20042009 // SSH keys — what the SSH listener authenticates against. Public keys are
@@ -2007,8 +2012,10 @@ export function apiRoutes(router: Router<Handler>): void {
20072012 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
20082013 const user = ctx.auth.user;
20092014 if (!user) return unauthorized();
2015 const window = keyedWindow(ctx);
2016 if (window instanceof Response) return window;
20102017 const keys = timePhase(ctx.request, "keys", () => listKeys(ctx.app.db, user.id));
2011 return timePhase(ctx.request, "render", () => json(keys.map(publicKey)));
2018 return timePhase(ctx.request, "render", () => json(keyedPage(keys.map(publicKey), (k) => k.id, window)));
20122019 });
20132020 router.add("POST", "/api/v1/user/keys", async (ctx) => {
20142021 const denied = requireScope(ctx, "rw");
@@ -5188,6 +5195,8 @@ export function apiRoutes(router: Router<Handler>): void {
51885195 router.add("GET", "/api/v1/user/pins", (ctx) => {
51895196 if (!ctx.auth.user) return unauthorized();
51905197 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
5198 const window = keyedWindow(ctx);
5199 if (window instanceof Response) return window;
51915200 const host = ctx.app.config.host ?? ctx.url.hostname;
51925201 const sshPort = ctx.app.config.sshPort;
51935202 const user = ctx.auth.user;
@@ -5221,7 +5230,7 @@ export function apiRoutes(router: Router<Handler>): void {
52215230 },
52225231 ];
52235232 });
5224 return json(items);
5233 return json(keyedPage(items, (p) => `${p.owner}/${p.name}`, window));
52255234 });
52265235 });
52275236
test/api-keys-timing.test.ts +1 −1
@@ -54,5 +54,5 @@ test("the keys list names session, keys, render in order", async () => {
5454 const got = await phases("/api/v1/user/keys");
5555 expect(got.status).toBe(200);
5656 expect(got.names).toEqual(["session", "keys", "render"]);
57 expect(Array.isArray(JSON.parse(got.body))).toBe(true);
57 expect(Array.isArray(JSON.parse(got.body).items)).toBe(true);
5858 });
test/api-list-shape.test.ts −3
@@ -17,9 +17,6 @@ import { seedAdmin } from "./helpers/admin.ts";
1717
1818 /** Routes that answer a bare array today → the open issue that changes them. */
1919 const BARE: Record<string, string> = {
20 "/api/v1/user/orgs": "#5043",
21 "/api/v1/user/keys": "#5043",
22 "/api/v1/user/pins": "#5043",
2320 "/api/v1/repos/:owner/:name/forks": "#4843",
2421 "/api/v1/repos/:owner/:name/labels": "#4843",
2522 "/api/v1/repos/:owner/:name/hooks": "#4843",
test/api-pins-timing.test.ts +1 −1
@@ -57,5 +57,5 @@ test("the pins list names session, pins, render in order", async () => {
5757 const got = await phases("/api/v1/user/pins");
5858 expect(got.status).toBe(200);
5959 expect(got.names).toEqual(["session", "pins", "render"]);
60 expect(Array.isArray(JSON.parse(got.body))).toBe(true);
60 expect(Array.isArray(JSON.parse(got.body).items)).toBe(true);
6161 });
test/cli-pins-pages.test.ts added +32
@@ -0,0 +1,32 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { ForgeApi } from "../src/cli/api.ts";
3
4 let server: ReturnType<typeof Bun.serve> | undefined;
5 afterEach(() => server?.stop(true));
6
7 const pin = (i: number) => ({ owner: "alice", name: `r${i}`, cloneUrl: `http://x/alice/r${i}.git` });
8
9 function stub(handler: (url: URL) => Response): ForgeApi {
10 server = Bun.serve({ port: 0, fetch: (req) => handler(new URL(req.url)) });
11 return new ForgeApi(`http://127.0.0.1:${server.port}`, "t");
12 }
13
14 test("pins() follows next_cursor across pages", async () => {
15 const seen: (string | null)[] = [];
16 const api = stub((url) => {
17 const cursor = url.searchParams.get("cursor");
18 seen.push(cursor);
19 return cursor === null
20 ? Response.json({ items: [pin(1), pin(2)], next_cursor: "2" })
21 : Response.json({ items: [pin(3)], next_cursor: null });
22 });
23 const out = await api.pins();
24 expect(out.ok && out.value.map((p) => p.name)).toEqual(["r1", "r2", "r3"]);
25 expect(seen).toEqual([null, "2"]);
26 });
27
28 test("pins() takes a bare array from an older forge as the whole list", async () => {
29 const api = stub(() => Response.json([pin(1), pin(2)]));
30 const out = await api.pins();
31 expect(out.ok && out.value.map((p) => p.name)).toEqual(["r1", "r2"]);
32 });
test/keys.test.ts +1 −1
@@ -253,7 +253,7 @@ describe.skipIf(NO_KEYGEN)("ssh keys", () => {
253253 const listed = await fetch(`${base}/api/v1/user/keys`, {
254254 headers: headers(ro),
255255 });
256 const keys = (await listed.json()) as { id: string }[];
256 const keys = ((await listed.json()) as { items: { id: string }[] }).items;
257257 expect(keys.map((k) => k.id)).toEqual([key.id]);
258258
259259 expect((await fetch(`${base}/api/v1/user/keys`)).status).toBe(401);
test/origins-security-auth.test.ts +12 −6
@@ -1090,9 +1090,11 @@ describe("authorization across origins, mirrors, pins and disconnect", () => {
10901090 // Carol sees none of Alice's pins, cannot remove them, and cannot pin a
10911091 // repo she cannot see — which does not exist for her.
10921092 expect(
1093 await read<unknown[]>(
1094 await seen(await api("/api/v1/user/pins", { as: "carol" })),
1095 ),
1093 (
1094 await read<{ items: unknown[] }>(
1095 await seen(await api("/api/v1/user/pins", { as: "carol" })),
1096 )
1097 ).items,
10961098 ).toEqual([]);
10971099 expect(
10981100 (
@@ -1119,12 +1121,16 @@ describe("authorization across origins, mirrors, pins and disconnect", () => {
11191121 ).status,
11201122 ).toBe(404);
11211123 expect(
1122 await read<unknown[]>(await api("/api/v1/user/pins", { as: "bob" })),
1124 (
1125 await read<{ items: unknown[] }>(
1126 await api("/api/v1/user/pins", { as: "bob" }),
1127 )
1128 ).items,
11231129 ).toEqual([]);
1124 const mine = await read<{ name: string }[]>(
1130 const mine = await read<{ items: { name: string }[] }>(
11251131 await seen(await api("/api/v1/user/pins")),
11261132 );
1127 expect(mine.map((p) => p.name).sort()).toEqual(["secret", "widgets"]);
1133 expect(mine.items.map((p) => p.name).sort()).toEqual(["secret", "widgets"]);
11281134 expect(
11291135 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM pin").get()!.n,
11301136 ).toBe(2);
test/origins.test.ts +9 −6
@@ -700,11 +700,13 @@ describe("origins: connecting, mirroring, writing through", () => {
700700 expect(
701701 (await api("/api/v1/user/pins/alice/widgets", { method: "PUT" })).status,
702702 ).toBe(200);
703 const pins = (await (await api("/api/v1/user/pins")).json()) as {
704 owner: string;
705 name: string;
706 cloneUrl: string;
707 }[];
703 const { items: pins, next_cursor } = (await (
704 await api("/api/v1/user/pins")
705 ).json()) as {
706 items: { owner: string; name: string; cloneUrl: string }[];
707 next_cursor: string | null;
708 };
709 expect(next_cursor).toBeNull();
708710 expect(pins).toHaveLength(1);
709711 expect(pins[0]).toMatchObject({ owner: "alice", name: "widgets" });
710712 expect(pins[0]!.cloneUrl.endsWith("/alice/widgets.git")).toBe(true);
@@ -730,7 +732,8 @@ describe("origins: connecting, mirroring, writing through", () => {
730732 .status,
731733 ).toBe(204);
732734 expect(
733 ((await (await api("/api/v1/user/pins")).json()) as unknown[]).length,
735 ((await (await api("/api/v1/user/pins")).json()) as { items: unknown[] })
736 .items.length,
734737 ).toBe(0);
735738 });
736739
test/parity.test.ts +6 −4
@@ -850,9 +850,11 @@ describe("parity: full control through actions and the API", () => {
850850 ).toBe(204);
851851
852852 const myOrgs = await call("GET", "/api/v1/user/orgs", { token: aliceRo });
853 expect(myOrgs.list.map((o) => o.username)).toEqual(["acme"]);
853 const orgItems = (r: { json: Json }) => (r.json.items ?? []) as Json[];
854 expect(orgItems(myOrgs).map((o) => o.username)).toEqual(["acme"]);
855 expect(myOrgs.json.next_cursor).toBeNull();
854856 expect(
855 (await call("GET", "/api/v1/user/orgs", { token: bobRw })).list,
857 orgItems(await call("GET", "/api/v1/user/orgs", { token: bobRw })),
856858 ).toEqual([]);
857859 for (const kind of [
858860 "team.created",
@@ -913,9 +915,9 @@ describe("parity: full control through actions and the API", () => {
913915 expect(res.json.error).toBe("invalid_limit");
914916 }
915917
916 // The cursor is only compared: any 1..100 characters, nothing else.
918 // The cursor is only compared: any 1..160 characters, nothing else.
917919 for (const path of ["teams", "teams/devs/members"])
918 for (const bad of ["", "x".repeat(101)]) {
920 for (const bad of ["", "x".repeat(161)]) {
919921 const res = await call("GET", `/api/v1/orgs/acme/${path}?cursor=${bad}`, {
920922 token: aliceAdmin,
921923 });
test/user-lists-page.test.ts added +167
@@ -0,0 +1,167 @@
1 /** The caller's SSH keys, pins and orgs answer `{ items, next_cursor }` with a bounded limit and a cursor. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createOrg } from "../src/actions/orgs.ts";
8 import { pinRepo } from "../src/actions/origins.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { addSshKey, deleteSshKey } from "../src/actions/users.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14 import { proveAll } from "./helpers/verified.ts";
15
16 type Page = { items: Record<string, unknown>[]; next_cursor: string | null };
17
18 /** A well-formed ed25519 line with random key bytes: no ssh-keygen needed. */
19 function ed25519Line(comment: string): string {
20 const field = (b: Uint8Array) => {
21 const out = new Uint8Array(4 + b.length);
22 new DataView(out.buffer).setUint32(0, b.length);
23 out.set(b, 4);
24 return out;
25 };
26 const algo = field(new TextEncoder().encode("ssh-ed25519"));
27 const pub = field(crypto.getRandomValues(new Uint8Array(32)));
28 const blob = new Uint8Array(algo.length + pub.length);
29 blob.set(algo);
30 blob.set(pub, algo.length);
31 return `ssh-ed25519 ${Buffer.from(blob).toString("base64")} ${comment}`;
32 }
33
34 describe("the caller's lists are pages", () => {
35 let dir: string;
36 let app: App;
37 let servers: Servers;
38 let token = "";
39 let aliceUser: Parameters<typeof deleteSshKey>[1];
40
41 const get = (path: string) =>
42 fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${token}` } });
43
44 /** Every page to the end with `limit=2`; the pages' sizes and all names. */
45 async function walk(path: string, name: string) {
46 const sizes: number[] = [];
47 const names: unknown[] = [];
48 let cursor: string | null = null;
49 for (let i = 0; i < 10; i++) {
50 const q: string = `limit=2${cursor === null ? "" : `&cursor=${cursor}`}`;
51 const page = (await (await get(`${path}?${q}`)).json()) as Page;
52 sizes.push(page.items.length);
53 names.push(...page.items.map((x) => x[name]));
54 cursor = page.next_cursor;
55 if (cursor === null) break;
56 }
57 return { sizes, names };
58 }
59
60 beforeAll(async () => {
61 dir = await mkdtemp(join(tmpdir(), "forge-user-lists-"));
62 app = createApp({ dataDir: dir, port: 0 });
63 servers = await serve(app);
64 const a = await seedAdmin(app, {
65 username: "alice",
66 email: "[email protected]",
67 password: "a-long-passphrase",
68 });
69 if (!a.ok) throw new Error("seed failed");
70 const alice = a.value;
71 aliceUser = alice;
72 proveAll(app);
73 for (const org of ["org-a", "org-b", "org-c"]) createOrg(app, alice, org);
74 for (const name of ["r1", "r2", "r3"]) {
75 const made = await createRepo(app, alice, { name, visibility: "private" });
76 if (!made.ok) throw new Error(`repo failed: ${made.error}`);
77 pinRepo(app, alice, made.value);
78 }
79 {
80 for (const n of ["k1", "k2", "k3"]) {
81 const added = addSshKey(app, alice, { name: n, key: ed25519Line(n) });
82 if (!added.ok) throw new Error("key failed");
83 }
84 }
85 const res = await fetch(`${servers.url}/api/v1/tokens`, {
86 method: "POST",
87 headers: { "content-type": "application/json" },
88 body: JSON.stringify({
89 adminConfirm: true,
90 username: "alice",
91 password: "a-long-passphrase",
92 name: "t",
93 scope: "ro",
94 }),
95 });
96 token = ((await res.json()) as { token: string }).token;
97 });
98
99 afterAll(async () => {
100 servers.stop();
101 app.close();
102 await rm(dir, { recursive: true, force: true });
103 });
104
105 const lists: [string, string, number][] = [
106 ["/api/v1/user/orgs", "username", 3],
107 ["/api/v1/user/pins", "name", 3],
108 ["/api/v1/user/keys", "name", 3],
109 ];
110
111 for (const [path, field, total] of lists) {
112 test(`${path}: shape, paging to the end, bad input refused`, async () => {
113 const whole = (await (await get(path)).json()) as Page;
114 expect(Array.isArray(whole.items)).toBe(true);
115 expect(whole.items).toHaveLength(total);
116 expect(whole.next_cursor).toBeNull();
117
118 const walked = await walk(path, field);
119 expect(walked.sizes).toEqual([2, 1]);
120 expect(walked.names.sort()).toEqual(whole.items.map((x) => x[field]).sort());
121
122 for (const bad of ["limit=0", "limit=201", "limit=abc", "limit=-1", "limit=1.5"]) {
123 const res = await get(`${path}?${bad}`);
124 expect(res.status).toBe(422);
125 expect(((await res.json()) as { error: string }).error).toBe("invalid_limit");
126 }
127 const res = await get(`${path}?cursor=${"x".repeat(161)}`);
128 expect(res.status).toBe(422);
129 expect(((await res.json()) as { error: string }).error).toBe("invalid_cursor");
130 expect((await get(`${path}?limit=200`)).status).toBe(200);
131 expect((await fetch(`${servers.url}${path}`)).status).toBe(401);
132 });
133 }
134
135 test("a key deleted between two pages skips nothing", async () => {
136 const first = (await (await get("/api/v1/user/keys?limit=1")).json()) as Page;
137 expect(first.items).toHaveLength(1);
138 expect(first.next_cursor).not.toBeNull();
139 const gone = deleteSshKey(app, aliceUser, String(first.items[0]!.id));
140 expect(gone.ok).toBe(true);
141 const second = (await (
142 await get(`/api/v1/user/keys?limit=5&cursor=${encodeURIComponent(first.next_cursor!)}`)
143 ).json()) as Page;
144 expect(second.items).toHaveLength(2);
145 expect(second.next_cursor).toBeNull();
146 });
147
148 test("a pin with a 100-character name pages across its boundary", async () => {
149 const name = "n".repeat(100);
150 const made = await createRepo(app, aliceUser, { name, visibility: "private" });
151 if (!made.ok) throw new Error(`repo failed: ${made.error}`);
152 pinRepo(app, aliceUser, made.value);
153 const seen: unknown[] = [];
154 let cursor: string | null = null;
155 for (let i = 0; i < 10; i++) {
156 const q: string = `limit=1${cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`}`;
157 const res = await get(`/api/v1/user/pins?${q}`);
158 expect(res.status).toBe(200);
159 const page = (await res.json()) as Page;
160 seen.push(...page.items.map((x) => x.name));
161 cursor = page.next_cursor;
162 if (cursor === null) break;
163 }
164 expect(seen).toHaveLength(4);
165 expect(seen).toContain(name);
166 });
167 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs