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

An organization's teams and a team's members answer `{ items, next_cursor }` #4848

Merged opened this

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

Merged

`GET /api/v1/orgs/:org/teams` and `GET /api/v1/orgs/:org/teams/:team/members` now answer `{ items, next_cursor }` instead of a bare array. I ran `parity.test.ts`, `org-owners.test.ts` and `design-audit.test.ts` and they pass, and `tsc --noEmit` is clean. I did not run the whole suite, and I did not try the app.

shipped production is serving it deploy b4d876b

opened this

GET /api/v1/orgs/:org/teams and GET /api/v1/orgs/:org/teams/:team/members now answer { items, next_cursor } instead of a bare array. I ran parity.test.ts, org-owners.test.ts and design-audit.test.ts and they pass, and tsc --noEmit is clean. I did not run the whole suite, and I did not try the app.

Behaviour

  • limit: default 50, max 200. Anything that isn't a whole number from 1 to 200 gets a 422 invalid_limit.
  • cursor: it is the last item's name (team name or username). The page that follows starts after it. A malformed cursor gets a 422 invalid_cursor. next_cursor is null on the last page, and it is also null when the last page ends exactly at the limit (unlike paged(), which can hand out a cursor to an empty page).
  • Permission and filter: these are unchanged. listTeamsVisibleTo and teamVisible still decide what is shown. An outsider asking for a private team's members still gets 404.
  • Code: two small helpers in src/http/api.ts, keyedWindow and keyedPage, do the paging. I did not touch other routes. GET /api/v1/orgs/:org still embeds a bare teams array, because the issue named only the two routes.
  • Callers: nothing in src/ read these two routes. The terminal UI, mcp.ts and the other /teams code use the crew-teams functions and don't call this API. The only readers were in test/parity.test.ts, and I updated them.

Left out: the paging happens in memory after the existing query, which is fine for org-sized lists. It is not a SQL keyset.

Tests: added in test/parity.test.ts.

  • The new shape.
  • Paging through teams and through members.
  • An outsider getting 404 on a private team's members.
  • A bad limit getting a 422 on both routes.

Closes #4842

commented

GET /api/v1/orgs/:org/teams and GET /api/v1/orgs/:org/teams/:team/members now answer { items, next_cursor } instead of a bare array. I ran parity.test.ts, org-owners.test.ts and design-audit.test.ts and they pass, and tsc --noEmit is clean. I did not run the whole suite, and I did not try the app.

Behaviour

  • limit: default 50, max 200. Anything that isn't a whole number from 1 to 200 gets a 422 invalid_limit.
  • cursor: it is the last item's name (team name or username). The page that follows starts after it. A malformed cursor gets a 422 invalid_cursor. next_cursor is null on the last page, and it is also null when the last page ends exactly at the limit (unlike paged(), which can hand out a cursor to an empty page).
  • Permission and filter: these are unchanged. listTeamsVisibleTo and teamVisible still decide what is shown. An outsider asking for a private team's members still gets 404.
  • Code: two small helpers in src/http/api.ts, keyedWindow and keyedPage, do the paging. I did not touch other routes. GET /api/v1/orgs/:org still embeds a bare teams array, because the issue named only the two routes.
  • Callers: nothing in src/ read these two routes. The terminal UI, mcp.ts and the other /teams code use the crew-teams functions and don't call this API. The only readers were in test/parity.test.ts, and I updated them.

Left out: the paging happens in memory after the existing query, which is fine for org-sized lists. It is not a SQL keyset.

Tests: added in test/parity.test.ts.

  • The new shape.
  • Paging through teams and through members.
  • An outsider getting 404 on a private team's members.
  • A bad limit getting a 422 on both routes.
Changes requested — before the latest push

The paging is right where it matters. Visibility is unchanged: listTeamsVisibleTo and teamVisible still run before paging, so nothing leaks across users. The sort and the cursor comparison use the same ordering. A bad limit is a 422 on both routes, and nothing in src/ read the bare array. Security is clean: the cursor is compared in memory only and never reaches SQL or a shell. On the preview, every request to /api/v1/orgs/trevato/teams answered the 404 of an unseen org, with and without limit and cursor. I found no org there to page through, so the tests are the evidence.

One thing to fix first: the cursor check can refuse a cursor this route issued itself, and that check has no test.

  • createTeam only trims a name and caps it at 60 characters, so a team may be named, for example, a\u0001b. When that team ends a page, next_cursor is that name, and the next request is refused with 422 invalid_cursor. A client paging the list gets stuck.
  • The fix: accept any string of 1 to 100 characters, or of at most the longest key either route can issue. The cursor is only compared, so no character class is needed.
  • Add a test on both routes: an empty cursor and an over-long cursor answer 422 invalid_cursor. Deleting that branch passes the suite today, and the bar is that every new behaviour has a test that would fail without it.

Nit: no test pins the default page size of 50. If the default changed, nothing would fail.

commented

Both findings are fixed and pushed. test/parity.test.ts and tsc --noEmit pass; I did not run the whole suite.

  • Cursor check: the cursor now only has to be 1 to 100 characters. The character class is gone, so a cursor the route issued for a name like a\u0001b is accepted. This is in keyedWindow in src/http/api.ts.
  • Cursor refusals: an empty cursor and a 101-character cursor now answer 422 invalid_cursor on both routes, and the test fails if that branch is deleted.
  • Control-character name: the test also creates a team named a\u0001b, checks it comes back as next_cursor, and checks the next request with it answers 200.
  • Default page size: the test creates 50 more teams and checks that an unparameterised list returns 50 items with a non-null next_cursor.

I did not add a separate cursor refusal test for the members route with a control-character name. That route uses the same keyedWindow check, and usernames are not free-form like team names.

Approved

Approved. The two routes now answer { items, next_cursor } as #4842 asked, and the change does nothing beyond that.

What I checked:

  • Security. Who sees what is unchanged: listTeamsVisibleTo gives a guest nothing, and teamVisible still returns 404 for a private team's members. The diff adds no write path. limit is checked as ^\d{1,4}$ and must fall in 1..200. The cursor is at most 100 characters and is only compared in memory, so it never reaches SQL or a shell. A team name can be at most 60 characters and is unique case-insensitively, so every cursor the route hands out is one it accepts again, and paging never skips or repeats a row. Sorting happens in JS on the same comparison the cursor filter uses, so the order stays consistent even though SQL ORDER BY orders bytes differently.
  • Callers. Nothing in src/ (tty, client, mcp, web) read these two GETs. The only test readers are in parity.test.ts, and they are updated.
  • Tests. I applied the diff to a copy of main. tsc --noEmit is clean. parity.test.ts, org-owners.test.ts and edits.test.ts give 32 pass, 0 fail. The new test covers the shape, paging through both lists, an outsider's 404, a bad limit and a bad cursor on both routes, a cursor from a control-character name, and the default of 50. Each of those assertions would fail without the change.
  • Preview. The preview has no data and offers no sign-in, so I could only check the guest path. A guest gets 404 on /api/v1/orgs/acme/teams, as before, and / answers.

Not blocking: paging happens in memory after the full query, which is fine at org size. listTeamMembers still loads each user with its own query; it did that before this pull.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs