Skip to content

Commit 921ad19

Browse files
Merge remote-tracking branch 'origin/staging' into fix/table-error
2 parents 132bf2f + 81e04a8 commit 921ad19

72 files changed

Lines changed: 2428 additions & 1060 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/integrations/agiloft.mdx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ In Sim, the Agiloft integration enables your agents to manage contracts and reco
3434

3535
## Usage Instructions
3636

37-
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.
37+
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.
3838

3939

4040

@@ -129,6 +129,7 @@ Delete a record from an Agiloft table.
129129
| `password` | string | Yes | Agiloft password |
130130
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
131131
| `recordId` | string | Yes | ID of the record to delete |
132+
| `deleteRule` | string | No | How to treat records that depend on this one: ERROR_IF_DEPENDANTS \(default — fails rather than cascading\), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, or UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE |
132133

133134
#### Output
134135

@@ -174,13 +175,14 @@ Lock, unlock, or check the lock status of an Agiloft record.
174175
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
175176
| `recordId` | string | Yes | ID of the record to lock, unlock, or check |
176177
| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" |
178+
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |
177179

178180
#### Output
179181

180182
| Parameter | Type | Description |
181183
| --------- | ---- | ----------- |
182184
| `id` | string | Record ID |
183-
| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) |
185+
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
184186
| `lockedBy` | string | Username of the user who locked the record |
185187
| `lockExpiresInMinutes` | number | Minutes until the lock expires |
186188

@@ -255,9 +257,9 @@ Download an attached file from an Agiloft record field.
255257
| --------- | ---- | ----------- |
256258
| `file` | file | Downloaded attachment file |
257259

258-
### Agiloft Saved Search
260+
### Agiloft Run Action Button
259261

260-
List saved searches defined for an Agiloft table.
262+
Run an action button on an Agiloft record, such as an approval or send-for-signature step.
261263

262264
#### Input
263265

@@ -267,17 +269,40 @@ List saved searches defined for an Agiloft table.
267269
| `knowledgeBase` | string | Yes | Knowledge base name |
268270
| `login` | string | Yes | Agiloft username |
269271
| `password` | string | Yes | Agiloft password |
270-
| `table` | string | Yes | Table name to list saved searches for \(e.g., "contracts"\) |
272+
| `table` | string | Yes | Table name \(e.g., "contracts", "case"\) |
273+
| `recordId` | string | Yes | ID of the record to run the action button on |
274+
| `actionButtonField` | string | Yes | Logical name of the field holding the action button \(e.g., "ab_field"\) |
271275

272276
#### Output
273277

274278
| Parameter | Type | Description |
275279
| --------- | ---- | ----------- |
276-
| `searches` | array | List of saved searches for the table |
277-
|`name` | string | Saved search name |
278-
|`label` | string | Saved search display label |
279-
|`id` | number | Saved search database identifier |
280-
|`description` | string | Saved search description |
280+
| `recordId` | string | ID of the record the action button was run on |
281+
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
282+
283+
### saved_search
284+
285+
286+
### Agiloft Saved Search (retired)
287+
288+
Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.
289+
290+
#### Input
291+
292+
| Parameter | Type | Required | Description |
293+
| --------- | ---- | -------- | ----------- |
294+
| `instanceUrl` | string | No | Agiloft instance URL |
295+
| `knowledgeBase` | string | No | Knowledge base name |
296+
| `login` | string | No | Agiloft username |
297+
| `password` | string | No | Agiloft password |
298+
| `table` | string | No | Table name |
299+
| `output` | string | No | No description |
300+
301+
#### Output
302+
303+
| Parameter | Type | Description |
304+
| --------- | ---- | ----------- |
305+
| `searches` | array | Always empty; this operation is retired |
281306

282307
### Agiloft Search Records
283308

@@ -292,19 +317,20 @@ Search for records in an Agiloft table using a query.
292317
| `login` | string | Yes | Agiloft username |
293318
| `password` | string | Yes | Agiloft password |
294319
| `table` | string | Yes | Table name to search in \(e.g., "contracts", "contacts.employees"\) |
295-
| `query` | string | Yes | Search query using Agiloft query syntax \(e.g., "status=\'Active\'" or "company_name~=\'Acme\'"\) |
320+
| `query` | string | No | Ad hoc EWSearch query. Combine conditions with && \(and\) or \|\| \(or\) and quote every value — e.g. \"summary~='test'&&priority='High'\". Required unless a saved search is given. |
321+
| `search` | string | No | Label of a saved search defined on the table \(e.g., "C: Status is Closed"\). Can be combined with a query to narrow it further. |
296322
| `fields` | string | No | Comma-separated list of field names to include in the results |
297323
| `page` | string | No | Page number for paginated results \(starting from 0\) |
298-
| `limit` | string | No | Maximum number of records to return per page |
324+
| `limit` | string | No | Maximum number of records to return per page. Agiloft treats 0 as "all records", so leave it unset or use a positive value to keep result sizes bounded. |
299325

300326
#### Output
301327

302328
| Parameter | Type | Description |
303329
| --------- | ---- | ----------- |
304330
| `records` | json | Array of matching records with their field values |
305-
| `totalCount` | number | Total number of matching records |
306-
| `page` | number | Current page number |
307-
| `limit` | number | Records per page |
331+
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
332+
| `page` | number | Page number that was requested \(0-based\) |
333+
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |
308334

309335
### Agiloft Select Records
310336

@@ -319,7 +345,7 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
319345
| `login` | string | Yes | Agiloft username |
320346
| `password` | string | Yes | Agiloft password |
321347
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
322-
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\) |
348+
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\). EWSelect has no page size and returns every matching ID, so append a database limit such as "limit 0,200" to bound the result. |
323349

324350
#### Output
325351

apps/sim/app/api/auth/forget-password/route.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@
66
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
77
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

9+
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
10+
mockCheckRateLimitDirect: vi.fn(),
11+
}))
12+
13+
/**
14+
* Mocked at the storage boundary rather than at `route-helpers`, so the real
15+
* key derivation (normalize + hash) is exercised through the route.
16+
*/
17+
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
18+
RateLimiter: class {
19+
checkRateLimitDirect = mockCheckRateLimitDirect
20+
},
21+
}))
22+
23+
const allowAll = () => ({ allowed: true, resetAt: new Date(Date.now() + 60_000) })
24+
25+
function exhaust(dimension: string, resetAt: Date) {
26+
mockCheckRateLimitDirect.mockImplementation(async (key: string) =>
27+
key.includes(dimension) ? { allowed: false, resetAt } : allowAll()
28+
)
29+
}
30+
31+
function recipientKeys(): string[] {
32+
return mockCheckRateLimitDirect.mock.calls
33+
.map(([key]) => key as string)
34+
.filter((key) => key.includes(':recipient:'))
35+
}
36+
937
const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => {
1038
const logger = {
1139
info: vi.fn(),
@@ -42,6 +70,7 @@ describe('Forget Password API Route', () => {
4270
vi.clearAllMocks()
4371
setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.example.com' })
4472
mockRequestPasswordReset.mockResolvedValue(undefined)
73+
mockCheckRateLimitDirect.mockImplementation(async () => allowAll())
4574
})
4675

4776
afterAll(() => {
@@ -73,6 +102,42 @@ describe('Forget Password API Route', () => {
73102
})
74103
})
75104

105+
it('rejects with 429 once the recipient budget is spent, without sending mail', async () => {
106+
const resetAt = new Date(Date.now() + 900_000)
107+
exhaust(':recipient:', resetAt)
108+
109+
const response = await POST(createMockRequest('POST', { email: 'test@example.com' }))
110+
111+
expect(response.status).toBe(429)
112+
expect(response.headers.get('Retry-After')).toBe('900')
113+
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
114+
})
115+
116+
it('buckets addresses that normalize to the same recipient together', async () => {
117+
await POST(createMockRequest('POST', { email: 'Test@Example.com' }))
118+
await POST(createMockRequest('POST', { email: 'test@example.com' }))
119+
120+
const [first, second] = recipientKeys()
121+
expect(first).toBe(second)
122+
})
123+
124+
it('keys the recipient bucket by hash, never the raw address', async () => {
125+
await POST(createMockRequest('POST', { email: 'test@example.com' }))
126+
127+
const [key] = recipientKeys()
128+
expect(key).toMatch(/^route:forget-password:recipient:[0-9a-f]{64}$/)
129+
})
130+
131+
it('short-circuits on the per-IP budget before spending the recipient budget', async () => {
132+
exhaust(':ip:', new Date(Date.now() + 60_000))
133+
134+
const response = await POST(createMockRequest('POST', { email: 'test@example.com' }))
135+
136+
expect(response.status).toBe(429)
137+
expect(recipientKeys()).toHaveLength(0)
138+
expect(mockRequestPasswordReset).not.toHaveBeenCalled()
139+
})
140+
76141
it('should reject external redirectTo URL', async () => {
77142
const req = createMockRequest('POST', {
78143
email: 'test@example.com',

apps/sim/app/api/auth/forget-password/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,29 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { forgetPasswordContract } from '@/lib/api/contracts'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { auth } from '@/lib/auth'
10+
import {
11+
enforceIpRateLimit,
12+
enforceRecipientRateLimit,
13+
type TokenBucketConfig,
14+
} from '@/lib/core/rate-limiter'
1015
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1116

1217
export const dynamic = 'force-dynamic'
1318

1419
const logger = createLogger('ForgetPasswordAPI')
1520

21+
/** Sized to absorb a frustrated user retrying, not to be tight. */
22+
const RESET_EMAIL_RATE_LIMIT: TokenBucketConfig = {
23+
maxTokens: 5,
24+
refillRate: 5,
25+
refillIntervalMs: 15 * 60_000,
26+
}
27+
1628
export const POST = withRouteHandler(async (request: NextRequest) => {
1729
try {
30+
const ipRateLimited = await enforceIpRateLimit('forget-password', request)
31+
if (ipRateLimited) return ipRateLimited
32+
1833
const parsed = await parseRequest(
1934
forgetPasswordContract,
2035
request,
@@ -33,6 +48,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3348

3449
const { email, redirectTo } = parsed.data.body
3550

51+
/**
52+
* Enforced before any lookup, and identically whether or not the account
53+
* exists, so a 429 discloses nothing the success response doesn't already.
54+
*/
55+
const recipientRateLimited = await enforceRecipientRateLimit(
56+
'forget-password',
57+
email,
58+
RESET_EMAIL_RATE_LIMIT
59+
)
60+
if (recipientRateLimited) return recipientRateLimited
61+
3662
await auth.api.requestPasswordReset({
3763
body: {
3864
email,

apps/sim/app/api/auth/reset-password/route.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
import { createMockRequest } from '@sim/testing'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

9+
const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
10+
mockCheckRateLimitDirect: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/core/rate-limiter/rate-limiter', () => ({
14+
RateLimiter: class {
15+
checkRateLimitDirect = mockCheckRateLimitDirect
16+
},
17+
}))
18+
919
const { mockResetPassword, mockLogger } = vi.hoisted(() => {
1020
const logger = {
1121
info: vi.fn(),
@@ -41,12 +51,31 @@ describe('Reset Password API Route', () => {
4151
beforeEach(() => {
4252
vi.clearAllMocks()
4353
mockResetPassword.mockResolvedValue(undefined)
54+
mockCheckRateLimitDirect.mockResolvedValue({
55+
allowed: true,
56+
resetAt: new Date(Date.now() + 60_000),
57+
})
4458
})
4559

4660
afterEach(() => {
4761
vi.clearAllMocks()
4862
})
4963

64+
it('rejects with 429 once the per-IP budget is spent, without consuming the token', async () => {
65+
mockCheckRateLimitDirect.mockResolvedValue({
66+
allowed: false,
67+
resetAt: new Date(Date.now() + 900_000),
68+
})
69+
70+
const response = await POST(
71+
createMockRequest('POST', { token: 'guess', newPassword: 'newSecurePassword123!' })
72+
)
73+
74+
expect(response.status).toBe(429)
75+
expect(response.headers.get('Retry-After')).toBe('900')
76+
expect(mockResetPassword).not.toHaveBeenCalled()
77+
})
78+
5079
it('should reset password successfully', async () => {
5180
const req = createMockRequest('POST', {
5281
token: 'valid-reset-token',

apps/sim/app/api/auth/reset-password/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,32 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { resetPasswordContract } from '@/lib/api/contracts'
44
import { parseRequest } from '@/lib/api/server'
55
import { auth } from '@/lib/auth'
6+
import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter'
67
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
78

89
export const dynamic = 'force-dynamic'
910

1011
const logger = createLogger('PasswordResetAPI')
1112

13+
/**
14+
* Submitting reset tokens without a session is guessing; a legitimate user
15+
* submits once. Tighter than the public default for that reason.
16+
*/
17+
const RESET_PASSWORD_RATE_LIMIT: TokenBucketConfig = {
18+
maxTokens: 10,
19+
refillRate: 10,
20+
refillIntervalMs: 15 * 60_000,
21+
}
22+
1223
export const POST = withRouteHandler(async (request: NextRequest) => {
1324
try {
25+
const rateLimited = await enforceIpRateLimit(
26+
'reset-password',
27+
request,
28+
RESET_PASSWORD_RATE_LIMIT
29+
)
30+
if (rateLimited) return rateLimited
31+
1432
const parsed = await parseRequest(
1533
resetPasswordContract,
1634
request,

apps/sim/app/api/table/[tableId]/groups/route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ interface CapturedDefinition {
88
auth: unknown
99
operation: { id: string }
1010
useCase: unknown
11+
mapInput(input: {
12+
params: { tableId: string }
13+
body: {
14+
workspaceId: string
15+
group: Record<string, unknown>
16+
outputColumns: Record<string, unknown>[]
17+
autoRun?: boolean
18+
}
19+
}): Record<string, unknown>
1120
}
1221

1322
const mocks = vi.hoisted(() => ({
@@ -70,4 +79,27 @@ describe('/api/table/[tableId]/groups', () => {
7079
expect(route.operation.id).toBe(useCase.operation.id)
7180
}
7281
})
82+
83+
it('preserves the legacy create default while honoring an explicit opt-out', () => {
84+
const route = definition('POST')
85+
const input = {
86+
params: { tableId: 'table-1' },
87+
body: {
88+
workspaceId: 'workspace-1',
89+
group: { id: 'group-1' },
90+
outputColumns: [{ name: 'Result' }],
91+
},
92+
}
93+
94+
expect(route.mapInput(input)).toEqual({
95+
tableId: 'table-1',
96+
...input.body,
97+
autoRun: true,
98+
})
99+
expect(route.mapInput({ ...input, body: { ...input.body, autoRun: false } })).toEqual({
100+
tableId: 'table-1',
101+
...input.body,
102+
autoRun: false,
103+
})
104+
})
73105
})

apps/sim/app/api/table/[tableId]/groups/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ export const POST = defineInternalJsonRoute({
4848
auth: internalTableSessionOrExecutorAuth,
4949
rateLimit,
5050
errorPolicy,
51-
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
51+
mapInput: ({ params, body }) => ({
52+
tableId: params.tableId,
53+
...body,
54+
autoRun: body.autoRun ?? true,
55+
}),
5256
present: ({ table }) => presentTable(table),
5357
})
5458

0 commit comments

Comments
 (0)