fix(web): harden paged search protocol (round 7)
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user