fix(web): harden paged search protocol (round 7)

This commit is contained in:
Hypatia May
2026-07-27 13:24:39 +08:00
parent a8c28be1ba
commit 40b68cd8d5
2 changed files with 72 additions and 10 deletions
+19 -10
View File
@@ -667,16 +667,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
...cursor === undefined ? {} : { cursor },
}, { signal })
if (isAborted(signal)) return cancelled()
if (page.items.length > SESSION_SEARCH_LIMIT) {
const providerItemCount = page.items.length
if (providerItemCount > SESSION_SEARCH_LIMIT) {
throw new Error(
`session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`,
`session search provider returned ${providerItemCount} items; maximum is ${SESSION_SEARCH_LIMIT}`,
)
}
// Host visibility is the authorization boundary. Consume the
// provider's globally ranked stream rather than binding every
// visible id into one SQLite statement, then re-check complete
// provenance before emitting any snippet.
for (const hit of page.items) {
// provenance before emitting any snippet. Inspect exactly the
// declared array entries so a custom iterator cannot overproduce.
for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) {
const hit = page.items[itemIndex]
if (hit === undefined) {
throw new Error(`session search provider omitted item at index ${itemIndex}`)
}
if (authorized.length > SESSION_SEARCH_LIMIT) continue
if (
!visibleIds.has(hit.header.id)
|| hit.bestMatch.sessionId !== hit.header.id
@@ -689,14 +696,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessionId: hit.header.id,
snippet: hit.bestMatch.snippet,
})
if (authorized.length > SESSION_SEARCH_LIMIT) break
}
if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break
if (seenCursors.has(page.nextCursor)) {
throw new Error('session search provider repeated a continuation cursor')
const nextCursor = page.nextCursor
if (nextCursor !== undefined) {
if (seenCursors.has(nextCursor)) {
throw new Error('session search provider repeated a continuation cursor')
}
seenCursors.add(nextCursor)
}
seenCursors.add(page.nextCursor)
cursor = page.nextCursor
if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break
cursor = nextCursor
}
return ok(request, {
items: authorized.slice(0, SESSION_SEARCH_LIMIT),
@@ -272,6 +272,33 @@ describe('session.search', () => {
expect(iterate).not.toHaveBeenCalled()
})
it('inspects only numerically stored items when a compliant page overrides iteration', async () => {
const ctx = await baseContext()
const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of visible) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const stored = visible.slice(0, 1)
const iterate = vi.fn(() => visible.values())
Object.defineProperty(stored, Symbol.iterator, { value: iterate })
const searchSessions = vi.fn(() => Promise.resolve({ items: stored }))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('custom-iterator'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible-0', snippet: 'match 0' }],
hasMore: false,
},
})
expect(iterate).not.toHaveBeenCalled()
})
it('fails closed when the provider repeats a continuation cursor', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
@@ -292,6 +319,32 @@ describe('session.search', () => {
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('validates a repeated cursor before accepting the authorized lookahead', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('repeated-lookahead-cursor'),
new AbortController().signal,
)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response.result).not.toHaveProperty('value')
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))