fix(web/app): close the contract gaps found by the five-domain API audit
Five parallel audits (chat, space, pulls, issues/repo, git internals) compared
every `Apis.*` call site against the Rust handlers and the generated schemas.
The recurring failures were shape and encoding, not field names.
- services/api: percent-encode path parameters once in `invoke`. The generated
URL builders interpolate verbatim, so any branch, tag or release name
containing `/` split the route into extra segments and 404'd — those refs
could be created but never read back.
- chat: `list_pins` answers a bare `Vec<MessageDto>` (its utoipa body still
claims a `Page`); `list_emojis` answers `Page<EmojiDto>` while the picker read
it as an array and crashed on `.filter`. Fix both ends of that swap.
- settings: `my_suspensions` returns a bare array, not a page — the punishment
history was silently empty.
- members: `GET /spaces/{s}/members` rows are flat `MemberSummary`, but four
domains read `row.user`; every picker (add member, assignee, whitelist) was
filtering on `undefined` and rendered empty. Fold the identity into `user`
once in `lib/member-row.ts`.
- pulls: `update-branch` returns `{ pull, result }`, not a pull.
- repo: root commits no longer ask for a diff against the empty tree (the
server's `rev_parse` 404s on that object); they render an explicit empty
patch instead of rejecting the page. Commit paging stops at the `offset`
ceiling of 5000. Branch/tag/admin page sizes drop to the server's 100 cap.
- three-state fields that are single `Option`s server-side cannot be cleared
with `null` (`null` == absent): chat rate limits send `0`, repo description
and website send the empty string, and a ban with an expiry is finished with
a follow-up `update_ban`.
- issues: `attachment_ids` exists only on the create command; the edit form was
carrying it into `PATCH`, where it was dropped.
- space admin: the member routes take the membership UUID, not the numeric user
id — resolve it first, the way the ban helpers already did.
ZhenYi committed
Modified view/app/src/domains/chat/model/api.ts view/app/src/domains/chat/model/api.ts +17 −22 View view/app/src/domains/chat/model/api.ts
@@ -15,17 +15,17 @@
1515 */
1616
1717import { getLocale, translate } from '@app/i18n'
18import { type ApiEnvelope, unwrapApi } from '@app/lib'
18import { type ApiEnvelope, toMemberUserRow, unwrapApi } from '@app/lib'
19import type { MemberSummaryPage } from '@app/services/api'
1920import Apis from '@app/services/api'
2021import type {
2122 Attachment,
2223 ChatMessage,
23 CustomEmoji,
2424 DmBrief,
2525 Draft,
26 EmojiPage,
2627 MessageEdit,
2728 MessagePage,
28 PinnedPage,
2929 Presence,
3030 ReactionAgg,
3131 ReactionUser,
@@ -49,7 +49,9 @@
4949
5050export const getRoom = (roomId: string) => unwrapApi<RoomBrief>(Apis.chat.get_room({ pathParams: { room_id: roomId } }))
5151
52/** Three-state fields: `null` clears, `undefined` leaves untouched. */
52/** `undefined` leaves a field untouched. The backend takes single `Option`s
53 * here (only `parent_id` is three-state), so clearing goes through the empty
54 * value instead of `null`: `topic: ''` and `rate_limit_per_user: 0`. */
5355export const updateRoom = (
5456 roomId: string,
5557 cmd: {
@@ -196,11 +198,10 @@
196198 }),
197199 )
198200
199/** Offset-paged: `items` only, room pin lists are small by design. */
201/** The handler answers with a bare array (its own utoipa body annotation still
202 * claims a Page, which is why the generated type disagrees with the wire). */
200203export const listPins = (roomId: string) =>
201 unwrapApi<PinnedPage | null>(Apis.chat.list_pins({ pathParams: { room_id: roomId } })).then(
202 (page) => page?.items ?? [],
203 )
204 listOf<ChatMessage>(Apis.chat.list_pins({ pathParams: { room_id: roomId } }))
204205
205206/* -------------------------------------------------------------- read state */
206207
@@ -324,16 +325,9 @@
324325 * assignees — both are "who is in this space", read from the space endpoint).
325326 */
326327export const listSpaceMembers = (spaceName: string) =>
327 unwrapApi<{ items?: SpaceMemberRow[] } | null>(
328 Apis.space.list_space_members({
329 pathParams: { space_name: spaceName },
330 params: { limit: 100 },
331 }) as unknown as PromiseLike<ApiEnvelope<{ items?: SpaceMemberRow[] } | null>>,
332 ).then((page) =>
333 (page?.items ?? [])
334 .map((row) => ({ ...row, user_id: row.user?.user_id ?? '' }))
335 .filter((row): row is SpaceMemberOption => Boolean(row.user_id)),
336 )
328 unwrapApi<MemberSummaryPage | null>(
329 Apis.space.list_space_members({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
330 ).then((page) => (page?.items ?? []).map((row) => ({ ...toMemberUserRow(row), user_id: row.user_id })))
337331
338332/* ---------------------------------------------------------------- presence */
339333
@@ -345,11 +339,12 @@
345339
346340/* ------------------------------------------------------------------ emojis */
347341
342/** Paged: the handler returns `Page<EmojiDto>`, so read `items` (a bare-array
343 * read handed the picker the page object and crashed its `.filter`). */
348344export const listSpaceEmojis = (spaceName: string) =>
349 // The endpoint answers with a bare array, not the paged { items } envelope
350 // the generated type advertises: reading .items silently returned nothing,
351 // so a space's custom emojis never reached the picker.
352 listOf<CustomEmoji>(Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }))
345 unwrapApi<EmojiPage | null>(
346 Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
347 ).then((page) => page?.items ?? [])
353348
354349/* -------------------------------------------------------------- attachments */
355350
Modified view/app/src/domains/chat/model/chat.ts view/app/src/domains/chat/model/chat.ts +2 −3 View view/app/src/domains/chat/model/chat.ts
@@ -12,7 +12,6 @@
1212 EnvelopeMessageDto,
1313 EnvelopeMessagePage,
1414 EnvelopePageEmojiDto,
15 EnvelopePageMessageDto,
1615 EnvelopeReadStateDto,
1716 EnvelopeRoomBriefDto,
1817 EnvelopeRoomSettingDto,
@@ -49,9 +48,9 @@
4948export type ReadState = NonNullable<EnvelopeReadStateDto['data']>
5049export type Draft = NonNullable<EnvelopeDraftDto['data']>
5150export type SpaceReadStates = NonNullable<EnvelopeSpaceReadStates['data']>
52export type CustomEmoji = NonNullable<EnvelopePageEmojiDto['data']>['items'][number]
51export type EmojiPage = NonNullable<EnvelopePageEmojiDto['data']>
52export type CustomEmoji = EmojiPage['items'][number]
5353export type UploadIntent = NonNullable<EnvelopeUploadIntentDto['data']>
54export type PinnedPage = NonNullable<EnvelopePageMessageDto['data']>
5554export type UserInfo = UserDto
5655
5756/** What `POST /chat/rooms/{id}/messages` accepts; re-declared so the composer
Modified view/app/src/domains/chat/ui/chat-room.tsx view/app/src/domains/chat/ui/chat-room.tsx +1 −1 View view/app/src/domains/chat/ui/chat-room.tsx
@@ -982,7 +982,7 @@
982982 name: name || undefined,
983983 topic,
984984 is_private: Boolean(values.is_private),
985 rate_limit_per_user: Number.isFinite(rate) && rate > 0 ? rate : null,
985 rate_limit_per_user: Number.isFinite(rate) && rate > 0 ? rate : 0,
986986 })
987987 onChanged()
988988 return t('chat.room.saved')
Modified view/app/src/domains/issues/model/api.ts view/app/src/domains/issues/model/api.ts +14 −3 View view/app/src/domains/issues/model/api.ts
@@ -12,7 +12,8 @@
1212 * every `issueId` parameter below accepts either form.
1313 */
1414
15import { type ApiEnvelope, unwrapApi } from '@app/lib'
15import { type ApiEnvelope, toMemberUserRow, unwrapApi } from '@app/lib'
16import type { MemberSummaryPage } from '@app/services/api'
1617import Apis from '@app/services/api'
1718import type {
1819 Board,
@@ -302,9 +303,19 @@
302303
303304/* ---------------------------------------------------------------- members */
304305
305/** Assignable people: the space's members (issue assignees must be members). */
306/** Assignable people: the space's members (issue assignees must be members).
307 * The rows are flat `MemberSummary`s, so the identity is folded into `user`. */
306308export const listSpaceMembers = (space: string) =>
307 pageOf<SpaceMember>(Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }))
309 unwrapApi<MemberSummaryPage | null>(
310 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
311 ).then(
312 (page): IssuePage<SpaceMember> => ({
313 items: (page?.items ?? []).map((row) => toMemberUserRow(row) as unknown as SpaceMember),
314 total: page?.total ?? 0,
315 limit: page?.limit ?? 0,
316 offset: page?.offset ?? 0,
317 }),
318 )
308319
309320/* ------------------------------------------------------------ attachments */
310321
Modified view/app/src/domains/issues/ui/issue-form-modal.tsx view/app/src/domains/issues/ui/issue-form-modal.tsx +7 −3 View view/app/src/domains/issues/ui/issue-form-modal.tsx
@@ -225,7 +225,7 @@
225225 setBusy(true)
226226 setError(null)
227227 try {
228 const payload = {
228 const fields = {
229229 title: trimmed,
230230 description: description.trim() || null,
231231 content,
@@ -234,9 +234,13 @@
234234 due_at: dateApiValue(due),
235235 label_ids: labelIds,
236236 assignee_ids: assigneeIds,
237 attachment_ids: drafts.filter((draft) => draft.status === 'ready').map((draft) => draft.id),
238237 }
239 const saved = editing ? await updateIssue(boardRef, editing.id, payload) : await createIssue(boardRef, payload)
238 // `attachment_ids` only exists on the create command; `UpdateIssueCmd` has
239 // no such field, so carrying it into a PATCH silently dropped the drafts.
240 const attachmentIds = drafts.filter((draft) => draft.status === 'ready').map((draft) => draft.id)
241 const saved = editing
242 ? await updateIssue(boardRef, editing.id, fields)
243 : await createIssue(boardRef, { ...fields, attachment_ids: attachmentIds })
240244 onSaved(saved)
241245 onClose()
242246 } catch (err) {
Modified view/app/src/domains/pulls/model/api.ts view/app/src/domains/pulls/model/api.ts +8 −4 View view/app/src/domains/pulls/model/api.ts
@@ -7,7 +7,7 @@
77 */
88
99import { type ApiEnvelope, unwrapApi } from '@app/lib'
10import Apis from '@app/services/api'
10import Apis, { type EnvelopeUpdateBranchDto } from '@app/services/api'
1111import type {
1212 ChangedFile,
1313 InlineDraft,
@@ -101,8 +101,9 @@
101101 Apis.pulls.list_my_pulls({
102102 pathParams: { scope },
103103 params: {
104 // The inbox endpoint has no draft filter; `draft` maps to open.
105 state: opts.state && opts.state !== 'all' && opts.state !== 'draft' ? opts.state : 'open',
104 // The inbox endpoint has no draft filter, so `draft` maps to open; it does
105 // understand `all`, so pass that through instead of narrowing to open.
106 state: opts.state === 'draft' ? 'open' : (opts.state ?? 'open'),
106107 limit: opts.limit,
107108 offset: opts.offset ?? 0,
108109 },
@@ -142,8 +143,11 @@
142143export const mergePull = (ref: PullRef, cmd: MergeCmd) =>
143144 oneOf<MergeOutcome>(Apis.pulls.merge_pull({ pathParams: pathOf(ref), data: cmd }))
144145
146/** Answers `{ pull, result }`, not a bare pull. */
145147export const updateBranchPull = (ref: PullRef) =>
146 oneOf<Pull>(Apis.pulls.update_branch_pull({ pathParams: pathOf(ref) }))
148 oneOf<NonNullable<EnvelopeUpdateBranchDto['data']>>(
149 Apis.pulls.update_branch_pull({ pathParams: pathOf(ref) }),
150 ).then((res) => res.pull)
147151
148152export const getPullGate = (ref: PullRef) => oneOf<PullGate>(Apis.pulls.get_pull_gate({ pathParams: pathOf(ref) }))
149153
Modified view/app/src/domains/repo/branches/ui/branches-tab.tsx view/app/src/domains/repo/branches/ui/branches-tab.tsx +1 −1 View view/app/src/domains/repo/branches/ui/branches-tab.tsx
@@ -11,7 +11,7 @@
1111import { TagsSection } from './tags-section'
1212
1313/** One page is plenty for a branch list; anything beyond that is filtered. */
14const PAGE_LIMIT = 200
14const PAGE_LIMIT = 100
1515
1616export interface BranchesTabProps {
1717 space: string
Modified view/app/src/domains/repo/branches/ui/tags-section.tsx view/app/src/domains/repo/branches/ui/tags-section.tsx +1 −1 View view/app/src/domains/repo/branches/ui/tags-section.tsx
@@ -7,7 +7,7 @@
77import { Eye, GitCommitHorizontal, Plus, Tag } from 'lucide-react'
88import { useState } from 'react'
99
10const PAGE_LIMIT = 200
10const PAGE_LIMIT = 100
1111
1212export interface TagsSectionProps {
1313 space: string
Modified view/app/src/domains/repo/history/ui/commit-view.tsx view/app/src/domains/repo/history/ui/commit-view.tsx +18 −2 View view/app/src/domains/repo/history/ui/commit-view.tsx
@@ -96,6 +96,14 @@
9696 async () => {
9797 const commit = await fetchCommit(space, repo, sha)
9898 if (!commit) return null
99 // A root commit has no parent to diff against. The canonical empty tree is
100 // not in the object database of most repos, so the server's `rev_parse`
101 // 404s on it (`diff`/`diff/raw` against EMPTY_TREE) — skip the patch rather
102 // than reject the whole page.
103 if (commit.parents.length === 0) {
104 const none: DiffFile[] = []
105 return { commit, base: EMPTY_TREE, files: none, bytes: 0, root: true }
106 }
99107 const base = commit.parents[0] ?? EMPTY_TREE
100108 const [patch, numstat] = await Promise.all([
101109 fetchRawDiff(space, repo, base, commit.id),
@@ -103,7 +111,13 @@
103111 // already carries counts, so a failure here must not sink the page.
104112 fetchNumstat(space, repo, base, commit.id).catch(() => []),
105113 ])
106 return { commit, base, files: applyNumstat(splitDiffFiles(patch), numstat), bytes: patch.length }
114 return {
115 commit,
116 base,
117 files: applyNumstat(splitDiffFiles(patch), numstat),
118 bytes: patch.length,
119 root: false,
120 }
107121 },
108122 [space, repo, sha, retry],
109123 {
@@ -235,7 +249,9 @@
235249 </div>
236250 </div>
237251
238 {files.length === 0 ? <PanelNote text={t('repo.commit.noFiles')} /> : null}
252 {files.length === 0 ? (
253 <PanelNote text={t(data?.root ? 'repo.commit.rootNoDiff' : 'repo.commit.noFiles')} />
254 ) : null}
239255 <div className="space-y-3">
240256 {files.map((file) => {
241257 const open = expanded[file.path] ?? autoExpand
Modified view/app/src/domains/repo/history/ui/commits-tab.tsx view/app/src/domains/repo/history/ui/commits-tab.tsx +4 −2 View view/app/src/domains/repo/history/ui/commits-tab.tsx
@@ -8,6 +8,8 @@
88import { useMemo, useState } from 'react'
99
1010const PAGE_SIZE = 50
11/** The commit list rejects an `offset` beyond this (400, not a clamp). */
12const MAX_OFFSET = 5000
1113const MAX_TAG_CHIPS = 2
1214
1315export interface CommitsTabProps {
@@ -228,8 +230,8 @@
228230 <Button
229231 variant="secondary"
230232 className="h-8 gap-1 text-[12.5px]"
231 disabled={!page.hasMore || state.loading}
232 onClick={() => setSkip(skip + PAGE_SIZE)}
233 disabled={!page.hasMore || state.loading || skip + PAGE_SIZE > MAX_OFFSET}
234 onClick={() => setSkip(Math.min(skip + PAGE_SIZE, MAX_OFFSET))}
233235 >
234236 {t('repo.pager.next')} <ChevronRight className="h-3.5 w-3.5" aria-hidden />
235237 </Button>
Modified view/app/src/domains/repo/model/protection.ts view/app/src/domains/repo/model/protection.ts +9 −9 View view/app/src/domains/repo/model/protection.ts
@@ -5,14 +5,14 @@
55 * whitelist rows can only be managed against a known policy UUID).
66 */
77
8import { type ApiEnvelope, unwrapApi } from '@app/lib'
8import { toMemberUserRow, unwrapApi } from '@app/lib'
99import type {
1010 EnvelopeBranchPolicyDto,
1111 EnvelopeVecBranchWhitelistDto,
1212 EnvelopeVecTagWhitelistDto,
1313 WhitelistKind,
1414} from '@app/services/api'
15import Apis from '@app/services/api'
15import Apis, { type MemberSummaryPage } from '@app/services/api'
1616
1717export type { WhitelistKind }
1818
@@ -140,17 +140,17 @@
140140
141141/** Space members as user options for the whitelist pickers. */
142142export interface UserOption {
143 user_id: string
143 user_id: number
144144 username: string
145145 display_name: string
146146}
147147
148148export const listUserOptions = async (space: string): Promise<UserOption[]> => {
149 // The generic Page schema types `items` structurally; members rows are cast.
150 const page = await unwrapApi<{ items?: { user?: UserOption | null }[] } | null>(
151 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }) as unknown as Promise<
152 ApiEnvelope<{ items?: { user?: UserOption | null }[] } | null>
153 >,
149 const page = await unwrapApi<MemberSummaryPage | null>(
150 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
154151 )
155 return (page?.items ?? []).map((row) => row.user).filter((u): u is UserOption => Boolean(u?.user_id))
152 return (page?.items ?? []).map((row) => {
153 const user = toMemberUserRow(row).user
154 return { user_id: user.user_id, username: user.username, display_name: user.display_name }
155 })
156156}
Modified view/app/src/domains/repo/settings/ui/sections/general-settings.tsx view/app/src/domains/repo/settings/ui/sections/general-settings.tsx +4 −2 View view/app/src/domains/repo/settings/ui/sections/general-settings.tsx
@@ -98,8 +98,10 @@
9898 onClick={() =>
9999 void run(async () => {
100100 await updateRepo(space, repo, {
101 description: form.description.trim() || null,
102 website: form.website.trim() || null,
101 // These are single `Option<String>`s on the server: `null` reads
102 // as "unchanged", so clearing has to send the empty string.
103 description: form.description.trim(),
104 website: form.website.trim(),
103105 default_branch: form.defaultBranch || null,
104106 is_private: form.isPrivate,
105107 enable_lfs: form.enableLfs,
Modified view/app/src/i18n/messages/en/repo.ts view/app/src/i18n/messages/en/repo.ts +2 −1 View view/app/src/i18n/messages/en/repo.ts
@@ -191,7 +191,8 @@
191191 'repo.commit.noMessage': '(no commit message)',
192192 'repo.commit.unknownAuthor': 'Unknown author',
193193 'repo.commit.committer': 'Committer',
194 'repo.commit.root': 'First commit (against the empty tree)',
194 'repo.commit.root': 'First commit (no parent)',
195 'repo.commit.rootNoDiff': 'The first commit has no parent and the server cannot diff against the empty tree, so no patch is shown.',
195196 'repo.commit.compareTo': 'Compared to {sha}',
196197 'repo.commit.parentChip': 'Parent {sha}',
197198 'repo.commit.parents': '{count} parent commits',
Modified view/app/src/i18n/messages/zh-CN/repo.ts view/app/src/i18n/messages/zh-CN/repo.ts +2 −1 View view/app/src/i18n/messages/zh-CN/repo.ts
@@ -188,7 +188,8 @@
188188 'repo.commit.noMessage': '(无提交信息)',
189189 'repo.commit.unknownAuthor': '未知作者',
190190 'repo.commit.committer': '提交者',
191 'repo.commit.root': '首个提交(对比空树)',
191 'repo.commit.root': '首个提交(无父提交)',
192 'repo.commit.rootNoDiff': '首个提交没有父提交,服务端无法为空树生成 diff,此处不显示补丁。',
192193 'repo.commit.compareTo': '对比 {sha}',
193194 'repo.commit.parentChip': '父 {sha}',
194195 'repo.commit.parents': '{count} 个父提交',
Modified view/app/src/lib/index.ts view/app/src/lib/index.ts +1 −0 View view/app/src/lib/index.ts
@@ -6,6 +6,7 @@
66
77export { getMinAge, getRegion } from './config'
88export { fmtDay, fmtRelative, fmtTime, formatBytes, shortSha } from './format'
9export { type MemberUserRow, toMemberUserRow } from './member-row'
910export {
1011 DEFAULT_PALETTE_ID,
1112 decodeCss,
Added view/app/src/lib/member-row.ts view/app/src/lib/member-row.ts +29 −0 View view/app/src/lib/member-row.ts
@@ -0,0 +1,29 @@
1import type { EnvelopePageMemberSummaryDataItemsItem, UserDto } from '@app/services/api'
2
3/**
4 * `GET /spaces/{space}/members` returns flat `MemberSummary` rows (`user_id`,
5 * `username`, `displayname`, `alias_name`, …) — there is no nested `user`
6 * object. Both the space settings and the assignee/add-member pickers read
7 * `row.user`, so the identity is folded into one here instead of at each call
8 * site: reading a missing `user` yields `undefined`, which filters every row
9 * away and leaves those pickers silently empty.
10 */
11export interface MemberUserRow {
12 user: UserDto
13 alias_name?: string | null
14 status?: string | null
15}
16
17export function toMemberUserRow(row: EnvelopePageMemberSummaryDataItemsItem): MemberUserRow {
18 const display = row.displayname ?? row.alias_name ?? row.username
19 return {
20 user: {
21 user_id: row.user_id,
22 username: row.username,
23 display_name: display,
24 avatar_url: row.alias_avatar_url ?? '',
25 },
26 alias_name: row.alias_name,
27 status: undefined,
28 }
29}
Modified view/app/src/services/api/index.ts view/app/src/services/api/index.ts +12 −1 View view/app/src/services/api/index.ts
@@ -135,7 +135,13 @@
135135 error_code: 'bad_request',
136136 }
137137 }
138 for (const name of names) args.push(config.pathParams?.[name])
138 // The generated URL builders interpolate path values verbatim, so a branch,
139 // tag or file path containing `/` would split the route into extra segments
140 // (404 "unknown api route"). Percent-encode here, once, for every operation.
141 for (const name of names) {
142 const value = config.pathParams?.[name]
143 args.push(encodeURIComponent(String(value ?? '')))
144 }
139145 const queryArgs = entry.query ? query(config.params) : undefined
140146 if (entry.bodyFirst) {
141147 if (entry.body) args.push(config.data)
@@ -205,6 +211,11 @@
205211} from '@kenapi/apiclient/index.schemas'
206212export * from './ops'
207213
214/** `GET /spaces/{space}/members` page: rows are flat `MemberSummary`s. */
215export type MemberSummaryPage = NonNullable<
216 import('@kenapi/apiclient/index.schemas').EnvelopePageMemberSummary['data']
217>
218export type MemberSummaryRow = MemberSummaryPage['items'][number]
208219export type EnvelopeInviteSummary = import('@kenapi/apiclient/index.schemas').EnvelopeCreateInviteResponse
209220export interface RoomMemberRow {
210221 user: { user_id: number; username: string; display_name: string; avatar_url: string }