Skip to content

Commit 1bf6017

Browse files
icecrasher321claude
andcommitted
fix(api): read contracts by import, and retire two more stale declarations
Greptile flagged the audit's brace counter as blind to braces inside strings, template literals, regexes and comments. It was, but the bigger problem was that a text scan can only see contracts whose `method`/`path` are inline literals — the 70-plus built through `definePostSelector(path, …)` and friends were never checked at all. Comparing raw `defineRouteContract(` occurrences against parsed ones showed the scanner silently skipping declarations. Read the contracts by importing each contract module and inspecting its exported objects instead, the way `check-route-verbs.ts` already resolves the contract behind a route. Contract modules are pure Zod so importing them is safe; route files stay a static scan because importing one drags in `@sim/db`, auth and `next/server`. Barrels re-export the same object, so entries are keyed by identity. Coverage goes from 1125 contracts to 1283. That immediately surfaced two more instances of exactly what this PR retires. `/api/tools/confluence/page` kept its `PUT` and `DELETE` contracts after #7179 reduced the route to the selector `POST`, so both declared verbs the live route answers with 405. Neither is fetched — `lib/internal/confluence/execute-tool.ts` is the only consumer — so they become plain schemas like the knowledge one, and `executeOperation` now delegates to a schema form rather than growing a second pattern beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b4fb7fa commit 1bf6017

3 files changed

Lines changed: 118 additions & 74 deletions

File tree

apps/sim/lib/api/contracts/selectors/confluence.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -399,14 +399,16 @@ export const confluencePageSelectorContract = definePostSelector(
399399
z.object({ id: z.string(), title: z.string() }).passthrough()
400400
)
401401

402-
export const confluenceUpdatePageContract = defineConfluencePutContract(
403-
'/api/tools/confluence/page',
404-
confluenceUpdatePageBodySchema
405-
)
406-
export const confluenceDeletePageContract = defineConfluenceDeleteContract(
407-
'/api/tools/confluence/page',
408-
confluenceDeletePageBodySchema
409-
)
402+
/**
403+
* Page update and delete have no contract because they have no route: the
404+
* `PUT`/`DELETE` handlers on `/api/tools/confluence/page` were retired when the
405+
* tool moved in process, and the surviving selector `POST` on that path would
406+
* answer either verb with 405. `lib/internal/confluence/execute-tool.ts`
407+
* validates both against `confluenceUpdatePageBodySchema` /
408+
* `confluenceDeletePageBodySchema` directly.
409+
*/
410+
export type ConfluenceUpdatePageBody = z.output<typeof confluenceUpdatePageBodySchema>
411+
export type ConfluenceDeletePageBody = z.output<typeof confluenceDeletePageBodySchema>
410412
export const confluenceDeleteAttachmentContract = defineConfluenceDeleteContract(
411413
'/api/tools/confluence/attachment',
412414
confluenceDeleteAttachmentBodySchema
@@ -562,8 +564,6 @@ export const confluenceUserContract = defineConfluencePostContract(
562564

563565
export type ConfluencePagesBody = ContractBody<typeof confluencePagesSelectorContract>
564566
export type ConfluencePageBody = ContractBody<typeof confluencePageSelectorContract>
565-
export type ConfluenceUpdatePageBody = ContractBody<typeof confluenceUpdatePageContract>
566-
export type ConfluenceDeletePageBody = ContractBody<typeof confluenceDeletePageContract>
567567
export type ConfluenceDeleteAttachmentBody = ContractBody<typeof confluenceDeleteAttachmentContract>
568568
export type ConfluenceListAttachmentsQuery = ContractQuery<typeof confluenceListAttachmentsContract>
569569
export type ConfluenceListBlogPostsQuery = ContractQuery<typeof confluenceListBlogPostsContract>

apps/sim/lib/internal/confluence/execute-tool.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { getErrorMessage } from '@sim/utils/errors'
2-
import type { AnyApiRouteContract, ContractBody, ContractQuery } from '@/lib/api/contracts'
2+
import type {
3+
AnyApiRouteContract,
4+
ApiSchema,
5+
ContractBody,
6+
ContractQuery,
7+
} from '@/lib/api/contracts'
38
import {
49
confluenceBlogPostOperationContract,
510
confluenceCreateCommentContract,
@@ -10,7 +15,7 @@ import {
1015
confluenceDeleteBlogPostContract,
1116
confluenceDeleteCommentContract,
1217
confluenceDeleteLabelContract,
13-
confluenceDeletePageContract,
18+
confluenceDeletePageBodySchema,
1419
confluenceDeletePagePropertyContract,
1520
confluenceDeleteSpaceContract,
1621
confluenceGetSpaceContract,
@@ -37,7 +42,7 @@ import {
3742
confluenceTasksContract,
3843
confluenceUpdateBlogPostContract,
3944
confluenceUpdateCommentContract,
40-
confluenceUpdatePageContract,
45+
confluenceUpdatePageBodySchema,
4146
confluenceUpdateSpaceContract,
4247
confluenceUploadAttachmentContract,
4348
confluenceUserContract,
@@ -94,12 +99,10 @@ import type {
9499

95100
type ContractInput<C extends AnyApiRouteContract> = NonNullable<ContractBody<C> | ContractQuery<C>>
96101

97-
function parsePreparedRequest<C extends AnyApiRouteContract>(
98-
contract: C,
102+
function parsePreparedInput<T>(
103+
schema: ApiSchema,
99104
request: InternalToolOperationCall
100-
): { success: true; data: ContractInput<C> } | { success: false; response: Response } {
101-
const schema = contract.query ?? contract.body
102-
if (!schema) throw new Error(`Confluence contract ${contract.path} has no request input`)
105+
): { success: true; data: T } | { success: false; response: Response } {
103106
const parsed = schema.safeParse(request.input)
104107
if (!parsed.success) {
105108
return {
@@ -110,16 +113,21 @@ function parsePreparedRequest<C extends AnyApiRouteContract>(
110113
),
111114
}
112115
}
113-
return { success: true, data: parsed.data as ContractInput<C> }
116+
return { success: true, data: parsed.data as T }
114117
}
115118

116-
async function executeOperation<C extends AnyApiRouteContract>(
117-
contract: C,
119+
/**
120+
* Operations whose HTTP route was retired hold a bare request schema rather than
121+
* a contract, so they cannot declare a `method` and `path` nothing serves. The
122+
* contract form below feeds this the schema it would have parsed anyway.
123+
*/
124+
async function executeSchemaOperation<T>(
125+
schema: ApiSchema,
118126
request: InternalToolOperationCall,
119-
execute: (input: ContractInput<C>, context: ConfluenceOperationContext) => Promise<unknown>
127+
execute: (input: T, context: ConfluenceOperationContext) => Promise<unknown>
120128
): Promise<Response> {
121129
request.signal?.throwIfAborted()
122-
const parsed = parsePreparedRequest(contract, request)
130+
const parsed = parsePreparedInput<T>(schema, request)
123131
if (!parsed.success) return parsed.response
124132
try {
125133
const result = await execute(parsed.data, {
@@ -141,6 +149,16 @@ async function executeOperation<C extends AnyApiRouteContract>(
141149
}
142150
}
143151

152+
function executeOperation<C extends AnyApiRouteContract>(
153+
contract: C,
154+
request: InternalToolOperationCall,
155+
execute: (input: ContractInput<C>, context: ConfluenceOperationContext) => Promise<unknown>
156+
): Promise<Response> {
157+
const schema = contract.query ?? contract.body
158+
if (!schema) throw new Error(`Confluence contract ${contract.path} has no request input`)
159+
return executeSchemaOperation<ContractInput<C>>(schema, request, execute)
160+
}
161+
144162
export const executeConfluenceTool: InternalToolOperationHandler = async (request) => {
145163
switch (request.toolId) {
146164
case 'confluence_add_label':
@@ -194,7 +212,11 @@ export const executeConfluenceTool: InternalToolOperationHandler = async (reques
194212
case 'confluence_delete_label':
195213
return executeOperation(confluenceDeleteLabelContract, request, executeConfluenceDeleteLabel)
196214
case 'confluence_delete_page':
197-
return executeOperation(confluenceDeletePageContract, request, executeConfluenceDeletePage)
215+
return executeSchemaOperation(
216+
confluenceDeletePageBodySchema,
217+
request,
218+
executeConfluenceDeletePage
219+
)
198220
case 'confluence_delete_page_property':
199221
return executeOperation(
200222
confluenceDeletePagePropertyContract,
@@ -327,7 +349,11 @@ export const executeConfluenceTool: InternalToolOperationHandler = async (reques
327349
executeConfluenceSearchInSpace
328350
)
329351
case 'confluence_update':
330-
return executeOperation(confluenceUpdatePageContract, request, executeConfluenceUpdatePage)
352+
return executeSchemaOperation(
353+
confluenceUpdatePageBodySchema,
354+
request,
355+
executeConfluenceUpdatePage
356+
)
331357
case 'confluence_update_blogpost':
332358
return executeOperation(
333359
confluenceUpdateBlogPostContract,

scripts/check-api-contract-routes.ts

Lines changed: 67 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,18 @@
1515
* verb, endpoint is fine" and sends the caller looking in the wrong place. That
1616
* is the only case this script rejects, so it stays silent on the in-process
1717
* contracts whose routes were deleted outright.
18+
*
19+
* Contracts are read by importing each contract module and inspecting its
20+
* exported objects, the same way `check-route-verbs.ts` resolves the contract
21+
* behind a route. Scanning the source text instead would have to re-implement a
22+
* TypeScript lexer to know which braces are code and which sit inside a string,
23+
* template literal, regex or comment, and it could only ever see contracts whose
24+
* `method`/`path` are inline literals — the 70-plus built through helpers like
25+
* `definePostSelector(path, …)` would be invisible. Route files stay a static
26+
* scan on purpose: importing one drags in `@sim/db`, auth and `next/server`,
27+
* whereas contract modules are pure Zod.
1828
*/
29+
import { existsSync } from 'node:fs'
1930
import { readdir, readFile, stat } from 'node:fs/promises'
2031
import path from 'node:path'
2132

@@ -25,52 +36,35 @@ const APP_API_DIR = path.join(ROOT, 'apps/sim/app/api')
2536
const SKIP_DIRS = new Set(['node_modules', 'dist', '.next', '.turbo', 'coverage', '__tests__'])
2637
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const
2738

39+
type HttpMethod = (typeof HTTP_METHODS)[number]
40+
2841
interface DeclaredContract {
2942
name: string
30-
method: string
43+
method: HttpMethod
3144
routePath: string
32-
file: string
33-
line: number
45+
module: string
3446
}
3547

36-
async function walk(dir: string, results: string[] = []): Promise<string[]> {
48+
async function listContractModules(dir: string, results: string[] = []): Promise<string[]> {
3749
for (const entry of await readdir(dir, { withFileTypes: true })) {
3850
if (SKIP_DIRS.has(entry.name)) continue
3951
const full = path.join(dir, entry.name)
40-
if (entry.isDirectory()) await walk(full, results)
52+
if (entry.isDirectory()) await listContractModules(full, results)
4153
else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) results.push(full)
4254
}
4355
return results
4456
}
4557

46-
/** Reads the balanced object literal passed to each `defineRouteContract(` call. */
47-
function parseContracts(source: string, file: string): DeclaredContract[] {
48-
const found: DeclaredContract[] = []
49-
const opener = /defineRouteContract\s*\(\s*\{/g
50-
let match: RegExpExecArray | null
51-
while ((match = opener.exec(source))) {
52-
let cursor = match.index + match[0].length - 1
53-
const start = cursor
54-
let depth = 0
55-
for (; cursor < source.length; cursor++) {
56-
const char = source[cursor]
57-
if (char === '{') depth++
58-
else if (char === '}' && --depth === 0) break
59-
}
60-
const literal = source.slice(start, cursor + 1)
61-
const method = literal.match(/(?:^|[\s,{])method\s*:\s*'([A-Z]+)'/)?.[1]
62-
const routePath = literal.match(/(?:^|[\s,{])path\s*:\s*'([^']+)'/)?.[1]
63-
if (!method || !routePath) continue
64-
const preceding = source.slice(0, match.index)
65-
found.push({
66-
name: [...preceding.matchAll(/export\s+const\s+([A-Za-z0-9_]+)/g)].pop()?.[1] ?? 'anonymous',
67-
method,
68-
routePath,
69-
file: path.relative(ROOT, file),
70-
line: preceding.split('\n').length,
71-
})
72-
}
73-
return found
58+
function isRouteContract(value: unknown): value is { method: HttpMethod; path: string } {
59+
if (typeof value !== 'object' || value === null) return false
60+
const candidate = value as Record<string, unknown>
61+
return (
62+
typeof candidate.method === 'string' &&
63+
(HTTP_METHODS as readonly string[]).includes(candidate.method) &&
64+
typeof candidate.path === 'string' &&
65+
typeof candidate.response === 'object' &&
66+
candidate.response !== null
67+
)
7468
}
7569

7670
async function readIfFile(candidate: string): Promise<string | null> {
@@ -98,13 +92,8 @@ async function readRouteFile(routePath: string): Promise<string | null> {
9892

9993
for (let depth = segments.length; depth > 0; depth--) {
10094
const ancestor = path.join(APP_API_DIR, ...segments.slice(0, depth - 1))
101-
let entries
102-
try {
103-
entries = await readdir(ancestor, { withFileTypes: true })
104-
} catch {
105-
continue
106-
}
107-
for (const entry of entries) {
95+
if (!existsSync(ancestor)) continue
96+
for (const entry of await readdir(ancestor, { withFileTypes: true })) {
10897
if (!entry.isDirectory()) continue
10998
if (!entry.name.startsWith('[...') && !entry.name.startsWith('[[...')) continue
11099
const source = await readIfFile(path.join(ancestor, entry.name, 'route.ts'))
@@ -134,11 +123,42 @@ function exportedMethods(source: string): Set<string> {
134123
return methods
135124
}
136125

137-
async function main() {
138-
const contracts: DeclaredContract[] = []
139-
for (const file of await walk(CONTRACTS_DIR)) {
140-
contracts.push(...parseContracts(await readFile(file, 'utf8'), file))
126+
async function collectContracts(): Promise<DeclaredContract[]> {
127+
const modules = await listContractModules(CONTRACTS_DIR)
128+
// Barrels re-export the same object, so keying by identity keeps one entry per
129+
// contract. Defining modules sort before `index.ts` so the report names them.
130+
modules.sort((a, b) => {
131+
const aBarrel = path.basename(a) === 'index.ts'
132+
const bBarrel = path.basename(b) === 'index.ts'
133+
return aBarrel === bBarrel ? a.localeCompare(b) : aBarrel ? 1 : -1
134+
})
135+
136+
const seen = new Map<object, DeclaredContract>()
137+
for (const file of modules) {
138+
let loaded: Record<string, unknown>
139+
try {
140+
loaded = (await import(file)) as Record<string, unknown>
141+
} catch (error) {
142+
console.error(`✗ Could not import ${path.relative(ROOT, file)} to read its contracts:`)
143+
console.error(` ${error instanceof Error ? error.message : String(error)}`)
144+
process.exit(1)
145+
}
146+
for (const [name, value] of Object.entries(loaded)) {
147+
if (!isRouteContract(value)) continue
148+
if (seen.has(value)) continue
149+
seen.set(value, {
150+
name,
151+
method: value.method,
152+
routePath: value.path,
153+
module: path.relative(ROOT, file),
154+
})
155+
}
141156
}
157+
return [...seen.values()]
158+
}
159+
160+
async function main() {
161+
const contracts = await collectContracts()
142162

143163
const violations: Array<DeclaredContract & { served: string[] }> = []
144164
const inProcess: DeclaredContract[] = []
@@ -149,14 +169,12 @@ async function main() {
149169
continue
150170
}
151171
const served = exportedMethods(routeSource)
152-
if (!served.has(contract.method)) {
153-
violations.push({ ...contract, served: [...served].sort() })
154-
}
172+
if (!served.has(contract.method)) violations.push({ ...contract, served: [...served].sort() })
155173
}
156174

157175
if (process.argv.includes('--list-in-process')) {
158176
for (const c of [...inProcess].sort((a, b) => a.routePath.localeCompare(b.routePath))) {
159-
console.log(` ${c.method.padEnd(6)} ${c.routePath} ${c.name} (${c.file}:${c.line})`)
177+
console.log(` ${c.method.padEnd(6)} ${c.routePath} ${c.name} (${c.module})`)
160178
}
161179
}
162180

@@ -166,7 +184,7 @@ async function main() {
166184
)
167185
for (const v of violations) {
168186
console.error(` ${v.method} ${v.routePath}`)
169-
console.error(` contract: ${v.name} (${v.file}:${v.line})`)
187+
console.error(` contract: ${v.name} (${v.module})`)
170188
console.error(` route serves: ${v.served.join(', ') || '(no methods)'}`)
171189
console.error(
172190
` fix: export ${v.method} from the route, or drop the declaration if the endpoint is retired\n`

0 commit comments

Comments
 (0)