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 },
|
...cursor === undefined ? {} : { cursor },
|
||||||
}, { signal })
|
}, { signal })
|
||||||
if (isAborted(signal)) return cancelled()
|
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(
|
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
|
// Host visibility is the authorization boundary. Consume the
|
||||||
// provider's globally ranked stream rather than binding every
|
// provider's globally ranked stream rather than binding every
|
||||||
// visible id into one SQLite statement, then re-check complete
|
// visible id into one SQLite statement, then re-check complete
|
||||||
// provenance before emitting any snippet.
|
// provenance before emitting any snippet. Inspect exactly the
|
||||||
for (const hit of page.items) {
|
// 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 (
|
if (
|
||||||
!visibleIds.has(hit.header.id)
|
!visibleIds.has(hit.header.id)
|
||||||
|| hit.bestMatch.sessionId !== 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,
|
sessionId: hit.header.id,
|
||||||
snippet: hit.bestMatch.snippet,
|
snippet: hit.bestMatch.snippet,
|
||||||
})
|
})
|
||||||
if (authorized.length > SESSION_SEARCH_LIMIT) break
|
|
||||||
}
|
}
|
||||||
if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break
|
const nextCursor = page.nextCursor
|
||||||
if (seenCursors.has(page.nextCursor)) {
|
if (nextCursor !== undefined) {
|
||||||
throw new Error('session search provider repeated a continuation cursor')
|
if (seenCursors.has(nextCursor)) {
|
||||||
|
throw new Error('session search provider repeated a continuation cursor')
|
||||||
|
}
|
||||||
|
seenCursors.add(nextCursor)
|
||||||
}
|
}
|
||||||
seenCursors.add(page.nextCursor)
|
if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break
|
||||||
cursor = page.nextCursor
|
cursor = nextCursor
|
||||||
}
|
}
|
||||||
return ok(request, {
|
return ok(request, {
|
||||||
items: authorized.slice(0, SESSION_SEARCH_LIMIT),
|
items: authorized.slice(0, SESSION_SEARCH_LIMIT),
|
||||||
|
|||||||
@@ -272,6 +272,33 @@ describe('session.search', () => {
|
|||||||
expect(iterate).not.toHaveBeenCalled()
|
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 () => {
|
it('fails closed when the provider repeats a continuation cursor', async () => {
|
||||||
const ctx = await baseContext()
|
const ctx = await baseContext()
|
||||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||||
@@ -292,6 +319,32 @@ describe('session.search', () => {
|
|||||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
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 () => {
|
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
|
||||||
const ctx = await baseContext()
|
const ctx = await baseContext()
|
||||||
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||||
|
|||||||
Reference in New Issue
Block a user