Skip to content

Commit 5e0a2ce

Browse files
authored
refactor(table): move the internal row routes onto the application boundary (#6809)
* refactor(table): move the single-row route onto the application boundary The hottest table write path authorized in its own handler and queried the database from the adapter — the two things a surface adapter must never do. It now declares itself with defineInternalJsonRoute against the readRow, updateRow and deleteRow use cases: 127 lines instead of 274, with no db import, no drizzle import and no checkAccess. Doing that surfaced why the violation existed. Write-provenance resolution needs the canonical schema to map a caller's column key to the storage column it certifies, and the adapter could only do that because it was already loading the table illegally. The envelope is now split along the real seam: the adapter reads the header and payload field, which is transport, and the use case resolves the selections against the canonical table, which is domain. That split has to preserve a distinction the defaulting logic would erase. An internal caller that sends no envelope stays deliberately untracked; defaulting it to an exact-empty stamp would certify "this write introduced no secrets" on a runtime write that may well have introduced some. Only an interactive caller certifies exact-empty, over the storage columns its write actually persists. Two further changes fell out of it: present() now receives the same { principal, input } pair its sibling hooks responseHeaders and finalizeResponse already got. This route serves a session and a workflow execution on one path and owes them different column keyings, so rendering per caller kind is presentation rather than domain. That was a gap in the builder, not a special case for this route. tableRowWireSchema describes what the single-row routes actually return. The contract claimed a full TableRow, carrying the executions sidecar and Date objects — true of the list and query routes, and never true here. The hand- rolled handler was never checked against its own contract, so the drift was invisible until the builder started validating it. Wire changes, both deliberate and both narrower than before: a cross-tenant table now conceals as 404 where the old blanket handler answered 403, while an in-workspace role denial still answers 403. Nothing in hooks/queries/tables.ts branches on either. Verified to fail: forcing one keying, dropping the actor, pre-resolving the envelope, certifying an untracked internal write, and skipping the bundle completeness check each turn the covering tests red. * refactor(table): move the upsert route onto the application boundary Same shape as the single-row route: declares itself against upsertTableRow, hands the provenance envelope over unresolved, and derives its column keying from the principal rather than assuming one. The keying and presentation helpers the two routes shared are now in row-wire beside the translators they wrap, so a third route does not restate them. Two response details changed on purpose. The row now carries `position`, which the use case always had and this route alone omitted — every other single-row response already returned it, and the contract now describes one shape instead of three. The upsert result also carries read-back provenance, which the route previously assembled for itself. The surface had no route-level tests; it has six now, covering both caller keyings, both operations, and the envelope handover. * refactor(table): move the enrichment-detail route onto the application boundary The last internal adapter that queried the database for itself. It now runs through readTableRowEnrichmentDetail, a new use case that shares tableOperations.readRow — reading a cell's cascade breakdown is a projection of the same row under the same role, not a second semantic operation. Its tests move to the same seam and gain one the old suite could not express: a cross-tenant table now conceals rather than confirming it exists. * fix(table): mirror the storage rule when keying write provenance storageKeyByWireKey mapped an unrecognised id-keyed key to null, but rowDataToStorage persists that key when the caller is not writing strictly. A cell would have been written with no provenance recorded, under a stamp still marked complete — the same failure the bundle completeness check exists to prevent, arriving through the keying map instead of the selection set. The two wires genuinely differ and the code now says so: the name path drops an unrecognised key, so it maps to null; the id path stores what it is given, so every key it sends is a storage key. Unreachable today, since no delegated surface uses id keying and a session bundle is refused earlier. Fixed because the function's stated invariant — that it mirrors how the row data itself is normalized — was not true. Also corrects the delegated principal fixture in these tests, which used a kind that is not in the Principal union, so the subject-id branch was never actually exercised. It is now, and the scope check is asserted to receive the acting principal's own subject id. Verified to fail: restoring the schema-based lookup turns the covering test red. * fix(table): restore executor access to the migrated row routes The migration swapped checkSessionOrInternalAuth for the delegation policy, and that broke every Table block call to these endpoints in two ways at once. The old policy accepted a legacy internal token. The new one requires a delegation token, which the executor only mints when the tool asks for it — and none of the four table row tools did, so get/update/delete/upsert row would each have failed with a 401. The knowledge tools already declare it, because their routes migrated first. Even with a valid token the operations denied the caller: readRow, updateRow, deleteRow and upsertRow ran under a policy whose delegatedServices is ['copilot'], so the executor got a 403. They now use the tool-facing policy that already existed for the group operations. Neither was visible to the route tests, which mock the auth policy wholesale — so the gap is closed at the layer that actually decides: one test pinning that each tool requests delegation and that its operation admits the executor, mutation-verified against both failure modes. Also fixes findings from the review pass: the read surfaces no longer load an executions sidecar none of them put on the wire (two readers rather than a flag, so a caller cannot silently read an empty one); the provenance name index is built once per batch instead of once per row; the uniqueness comment now names the concurrent-insert race as well as the retro-added constraint; and the presenter context gets NoInfer plus a note that the v2 builder passes something different. * fix(table): keep the lock on a 423 and pin the remaining wire changes The rows error policy was built on the concealment base rather than the lock-aware one, so a TableLockedError fell through to the generic handler and the response lost its `lock` field — the only thing that tells a client which lock to clear. A row write is exactly as lockable as a group mutation, so it now shares that base. Also pins the two wire changes the review found undocumented: a mismatched workspace assertion answers 404 rather than 400, which is a superset of the cross-tenant concealment already intended, and an unclassified failure answers the builder's shared "Internal server error" rather than the old per-route text. Both are consistent with the ~80 routes already on this builder; they are asserted so they read as decisions rather than drift. Verified to fail: reverting the policy base turns the lock test red. * refactor(table): apply the quality pass Four parallel reviews (reuse, simplification, efficiency, altitude). The highest-value finding cut against the branch's own purpose: the rows error policy sat in the barrel-exported route-policies module, so its import of the 1,200-line row use-case graph was paid by every one of the ~28 table routes that can never throw a row error — the barrel's import cost went from ~1.1s to ~1.7s. row-route-policies.ts already existed for exactly this and is deliberately not re-exported; the policy now lives there with its v2 sibling. The upsert path still loaded an executions sidecar no surface puts on the wire, and did it inside the write transaction, holding it open for a discarded result. The read path got that fix earlier; the write path next to it did not. rowKeyingForPrincipal fell through to name keying for anything that was not a session. The operation policy admits API-key principals, so the first one to reach these routes would have had every id-keyed cell dropped and the write reported as successful. It is now an exhaustive switch over the two kinds the auth policy yields — which immediately failed three tests using a principal kind that exists nowhere in the repo, so those fixtures are real now too. Also: reuses toWireTimestamp and createUnknownTableRowSecretProvenance instead of re-inlining them; shares one helper for the provenance choice the update and upsert use cases both make; keeps one canonical actorClientId doc with two cross-references; merges two maps keyed by the same string into one; stops round-tripping the principal through the legacy AuthType enum; hoists the presenter function type out of both conditional branches; drops a subsumed test; and freezes the shared locks fixture so a mutating test cannot poison its siblings.
1 parent 8bfaee4 commit 5e0a2ce

28 files changed

Lines changed: 1501 additions & 780 deletions
Lines changed: 71 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,102 @@
11
/**
22
* @vitest-environment node
3+
*
4+
* The enrichment-detail surface after moving onto the shared internal route
5+
* builder. It previously queried the database from the adapter; the assertions
6+
* below are the same wire outcomes, now with the use case as the seam.
37
*/
4-
import { createTableDefinition, hybridAuthMockFns } from '@sim/testing'
58
import { NextRequest } from 'next/server'
69
import { beforeEach, describe, expect, it, vi } from 'vitest'
7-
import type { EnrichmentRunDetail } from '@/lib/table'
810

9-
const { mockCheckAccess, mockLoadEnrichmentDetail } = vi.hoisted(() => ({
10-
mockCheckAccess: vi.fn(),
11-
mockLoadEnrichmentDetail: vi.fn(),
11+
const { mocks } = vi.hoisted(() => ({
12+
mocks: { readDetail: vi.fn(), authenticate: vi.fn() },
1213
}))
1314

14-
vi.mock('@/lib/table/rows/executions', () => ({
15-
loadEnrichmentDetail: mockLoadEnrichmentDetail,
16-
}))
17-
vi.mock('@/app/api/table/utils', async () => {
18-
const { NextResponse } = await import('next/server')
15+
vi.mock('@/lib/table/application/rows', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('@/lib/table/application/rows')>()
1917
return {
20-
checkAccess: mockCheckAccess,
21-
accessError: (result: { status: number }) =>
22-
NextResponse.json({ error: 'denied' }, { status: result.status }),
18+
...actual,
19+
readTableRowEnrichmentDetail: {
20+
operation: { id: 'tables.rows.read' },
21+
execute: mocks.readDetail,
22+
},
2323
}
2424
})
2525

26+
vi.mock('@/lib/table/api', async (importOriginal) => {
27+
const actual = await importOriginal<typeof import('@/lib/table/api')>()
28+
return { ...actual, internalTableSessionOrExecutorAuth: { authenticate: mocks.authenticate } }
29+
})
30+
31+
import { InternalUnauthenticatedError } from '@/lib/api/server/routes'
32+
import { NoWorkspaceAccessError } from '@/lib/core/application'
2633
import { GET } from '@/app/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]/route'
2734

28-
function makeRequest(tableId = 'tbl_1', rowId = 'row_1', groupId = 'grp_1') {
29-
const req = new NextRequest(
30-
`http://localhost:3000/api/table/${tableId}/rows/${rowId}/enrichment/${groupId}`
31-
)
32-
return GET(req, { params: Promise.resolve({ tableId, rowId, groupId }) })
35+
const TABLE = { id: 'tbl_1', workspaceId: 'workspace-1', schema: { columns: [] } }
36+
const DETAIL = { providers: [{ id: 'clearbit', status: 'hit' }], costUsd: 0.01 }
37+
38+
function routeContext() {
39+
return {
40+
params: Promise.resolve({ tableId: 'tbl_1', rowId: 'row_1', groupId: 'grp_1' }),
41+
}
3342
}
3443

35-
const detail: EnrichmentRunDetail = {
36-
startedAt: '2026-06-18T00:00:00.000Z',
37-
completedAt: '2026-06-18T00:00:01.000Z',
38-
durationMs: 1000,
39-
totalCost: 0.05,
40-
matchedProvider: 'hunter',
41-
aborted: false,
42-
providers: [
43-
{
44-
id: 'hunter',
45-
label: 'Hunter',
46-
toolId: 'hunter_find_email',
47-
status: 'matched',
48-
cost: 0.05,
49-
durationMs: 1000,
50-
error: null,
51-
},
52-
],
44+
function request() {
45+
return new NextRequest('http://localhost/api/table/tbl_1/rows/row_1/enrichment/grp_1', {
46+
method: 'GET',
47+
})
5348
}
5449

50+
beforeEach(() => {
51+
vi.clearAllMocks()
52+
mocks.authenticate.mockResolvedValue({
53+
kind: 'session',
54+
userId: 'user-1',
55+
sessionId: 'session-1',
56+
})
57+
mocks.readDetail.mockResolvedValue({ table: TABLE, detail: DETAIL })
58+
})
59+
5560
describe('GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]', () => {
56-
beforeEach(() => {
57-
vi.clearAllMocks()
58-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
59-
success: true,
60-
userId: 'user-1',
61-
authType: 'session',
62-
})
63-
mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition({ rowCount: 1 }) })
61+
it('returns 401 when the caller is not authenticated', async () => {
62+
mocks.authenticate.mockRejectedValue(new InternalUnauthenticatedError())
63+
64+
const response = await GET(request(), routeContext())
65+
66+
expect(response.status).toBe(401)
67+
expect(mocks.readDetail).not.toHaveBeenCalled()
6468
})
6569

6670
it('returns the enrichment detail', async () => {
67-
mockLoadEnrichmentDetail.mockResolvedValue(detail)
68-
const res = await makeRequest()
69-
expect(res.status).toBe(200)
70-
const json = await res.json()
71-
expect(json).toEqual({ success: true, data: { detail } })
72-
expect(mockLoadEnrichmentDetail).toHaveBeenCalledWith(
73-
expect.anything(),
74-
'tbl_1',
75-
'row_1',
76-
'grp_1'
77-
)
71+
const response = await GET(request(), routeContext())
72+
73+
expect(response.status).toBe(200)
74+
await expect(response.json()).resolves.toEqual({ success: true, data: { detail: DETAIL } })
7875
})
7976

8077
it('returns null when there is no recorded run', async () => {
81-
mockLoadEnrichmentDetail.mockResolvedValue(null)
82-
const res = await makeRequest()
83-
expect(res.status).toBe(200)
84-
const json = await res.json()
85-
expect(json).toEqual({ success: true, data: { detail: null } })
78+
mocks.readDetail.mockResolvedValue({ table: TABLE, detail: null })
79+
80+
const body = await (await GET(request(), routeContext())).json()
81+
82+
expect(body).toEqual({ success: true, data: { detail: null } })
8683
})
8784

88-
it('401s when unauthenticated', async () => {
89-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
90-
const res = await makeRequest()
91-
expect(res.status).toBe(401)
92-
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
85+
it('passes the row and group through to the use case', async () => {
86+
await GET(request(), routeContext())
87+
88+
expect(mocks.readDetail.mock.calls[0][0].input).toMatchObject({
89+
tableId: 'tbl_1',
90+
rowId: 'row_1',
91+
groupId: 'grp_1',
92+
})
9393
})
9494

95-
it('denies when access check fails', async () => {
96-
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
97-
const res = await makeRequest()
98-
expect(res.status).toBe(403)
99-
expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled()
95+
it('conceals a cross-tenant table rather than confirming it exists', async () => {
96+
mocks.readDetail.mockRejectedValue(new NoWorkspaceAccessError())
97+
98+
const response = await GET(request(), routeContext())
99+
100+
expect(response.status).toBe(404)
100101
})
101102
})
Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,32 @@
1-
import { db } from '@sim/db'
2-
import { createLogger } from '@sim/logger'
3-
import { type NextRequest, NextResponse } from 'next/server'
41
import { getEnrichmentDetailContract } from '@/lib/api/contracts/tables'
5-
import { parseRequest } from '@/lib/api/server'
6-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7-
import { generateRequestId } from '@/lib/core/utils/request'
8-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
import { loadEnrichmentDetail } from '@/lib/table/rows/executions'
10-
import { accessError, checkAccess } from '@/app/api/table/utils'
2+
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
3+
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
4+
import { internalTableRowsErrorPolicy } from '@/lib/table/api/row-route-policies'
5+
import { tableOperations } from '@/lib/table/application/operations'
6+
import { readTableRowEnrichmentDetail } from '@/lib/table/application/rows'
117

12-
const logger = createLogger('EnrichmentDetailAPI')
13-
14-
interface RouteParams {
15-
params: Promise<{ tableId: string; rowId: string; groupId: string }>
16-
}
8+
export const dynamic = 'force-dynamic'
179

1810
/**
1911
* GET /api/table/[tableId]/rows/[rowId]/enrichment/[groupId]
2012
*
21-
* Returns the enrichment cascade breakdown (provider outcomes, cost, timing)
22-
* for one enrichment cell. Read on demand by the enrichment details panel —
23-
* this data is deliberately kept off the hot grid read. Returns `null` for
24-
* cells with no recorded run or runs that predate the feature.
13+
* The enrichment cascade breakdown — provider outcomes, cost, timing — for one
14+
* enrichment cell. Read on demand by the details panel; this data is
15+
* deliberately kept off the hot grid read.
2516
*/
26-
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
27-
const requestId = generateRequestId()
28-
29-
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
30-
if (!authResult.success || !authResult.userId) {
31-
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
32-
}
33-
34-
const parsed = await parseRequest(getEnrichmentDetailContract, request, { params })
35-
if (!parsed.success) return parsed.response
36-
const { tableId, rowId, groupId } = parsed.data.params
37-
38-
const result = await checkAccess(tableId, authResult.userId, 'read')
39-
if (!result.ok) return accessError(result, requestId, tableId)
40-
41-
const detail = await loadEnrichmentDetail(db, tableId, rowId, groupId)
42-
43-
logger.info(`[${requestId}] Loaded enrichment detail`, {
44-
tableId,
45-
rowId,
46-
groupId,
47-
hasDetail: detail !== null,
48-
})
49-
50-
return NextResponse.json({ success: true, data: { detail } })
17+
export const GET = defineInternalJsonRoute({
18+
contract: getEnrichmentDetailContract,
19+
operation: tableOperations.readRow,
20+
auth: internalTableSessionOrExecutorAuth,
21+
rateLimit: internalRateLimits.none({
22+
reason: 'Preserve existing internal enrichment-detail behavior',
23+
}),
24+
errorPolicy: internalTableRowsErrorPolicy,
25+
mapInput: ({ params }) => ({
26+
tableId: params.tableId,
27+
rowId: params.rowId,
28+
groupId: params.groupId,
29+
}),
30+
useCase: readTableRowEnrichmentDetail,
31+
present: ({ detail }) => ({ success: true as const, data: { detail } }),
5132
})

0 commit comments

Comments
 (0)