Skip to content

Commit 066b728

Browse files
committed
Merge branch 'staging' into feat/ppt-preview-improvement
2 parents ab2a723 + c7130c6 commit 066b728

25 files changed

Lines changed: 1890 additions & 908 deletions

File tree

apps/sim/app/api/auth/shopify/authorize/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import { generateId } from '@sim/utils/id'
33
import { type NextRequest, NextResponse } from 'next/server'
4-
import { shopifyAuthorizeQuerySchema } from '@/lib/api/contracts/oauth-connections'
4+
import {
5+
shopifyAuthorizeQuerySchema,
6+
shopifyShopDomainSchema,
7+
} from '@/lib/api/contracts/oauth-connections'
58
import { getSession } from '@/lib/auth'
69
import { env } from '@/lib/core/config/env'
710
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -161,6 +164,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
161164
cleanShop = `${cleanShop.replace('.myshopify.com', '')}.myshopify.com`
162165
}
163166

167+
if (!shopifyShopDomainSchema.safeParse(cleanShop).success) {
168+
logger.warn('Rejected invalid Shopify shop domain', { shop: shopDomain })
169+
return NextResponse.json({ error: 'Invalid Shopify shop domain' }, { status: 400 })
170+
}
171+
164172
const baseUrl = getBaseUrl()
165173
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify`
166174

apps/sim/app/api/table/import-csv/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
inferSchemaFromCsv,
1818
parseCsvBuffer,
1919
sanitizeName,
20+
TABLE_LIMITS,
2021
type TableSchema,
2122
} from '@/lib/table'
2223
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -67,7 +68,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6768
const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
6869

6970
const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows)
70-
const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table')
71+
const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice(
72+
0,
73+
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
74+
)
7175
const planLimits = await getWorkspaceTableLimits(workspaceId)
7276

7377
const normalizedSchema: TableSchema = {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Tests for workflow access middleware — focused on the workspace-scoped
3+
* API key boundary check in the `requireDeployment=false` branch.
4+
*
5+
* @vitest-environment node
6+
*/
7+
8+
import {
9+
hybridAuthMockFns,
10+
workflowAuthzMock,
11+
workflowAuthzMockFns,
12+
workflowsUtilsMock,
13+
workflowsUtilsMockFns,
14+
} from '@sim/testing'
15+
import { NextRequest } from 'next/server'
16+
import { beforeEach, describe, expect, it, vi } from 'vitest'
17+
18+
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
19+
vi.mock('@sim/workflow-authz', () => workflowAuthzMock)
20+
vi.mock('@/lib/api-key/service', () => ({
21+
authenticateApiKeyFromHeader: vi.fn(),
22+
updateApiKeyLastUsed: vi.fn(),
23+
}))
24+
25+
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
26+
27+
function makeRequest() {
28+
return new NextRequest(new URL('https://example.com/api/workflows/wf-1/log'))
29+
}
30+
31+
describe('validateWorkflowAccess (requireDeployment=false)', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
35+
id: 'wf-1',
36+
workspaceId: 'ws-A',
37+
isDeployed: true,
38+
})
39+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
40+
allowed: true,
41+
status: 200,
42+
workflow: { id: 'wf-1', workspaceId: 'ws-A' },
43+
})
44+
})
45+
46+
it('rejects a workspace-scoped API key issued for a different workspace', async () => {
47+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
48+
success: true,
49+
userId: 'user-1',
50+
authType: 'api_key',
51+
apiKeyType: 'workspace',
52+
workspaceId: 'ws-B',
53+
})
54+
55+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
56+
57+
expect(result.error).toEqual({
58+
message: 'API key is not authorized for this workspace',
59+
status: 403,
60+
})
61+
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
62+
})
63+
64+
it('allows a workspace-scoped API key issued for the matching workspace', async () => {
65+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
66+
success: true,
67+
userId: 'user-1',
68+
authType: 'api_key',
69+
apiKeyType: 'workspace',
70+
workspaceId: 'ws-A',
71+
})
72+
73+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
74+
75+
expect(result.error).toBeUndefined()
76+
expect(result.workflow).toBeDefined()
77+
expect(result.auth?.workspaceId).toBe('ws-A')
78+
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
79+
workflowId: 'wf-1',
80+
userId: 'user-1',
81+
action: 'read',
82+
})
83+
})
84+
85+
it('allows a personal API key regardless of workspaceId on the auth result', async () => {
86+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
87+
success: true,
88+
userId: 'user-1',
89+
authType: 'api_key',
90+
apiKeyType: 'personal',
91+
workspaceId: 'ws-B',
92+
})
93+
94+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
95+
96+
expect(result.error).toBeUndefined()
97+
expect(result.workflow).toBeDefined()
98+
})
99+
100+
it('allows session auth (no apiKeyType) when workspace permission grants access', async () => {
101+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
102+
success: true,
103+
userId: 'user-1',
104+
authType: 'session',
105+
})
106+
107+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
108+
109+
expect(result.error).toBeUndefined()
110+
expect(result.workflow).toBeDefined()
111+
})
112+
113+
it('still enforces workspace-permission rejection for personal keys', async () => {
114+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
115+
success: true,
116+
userId: 'user-1',
117+
authType: 'api_key',
118+
apiKeyType: 'personal',
119+
})
120+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
121+
allowed: false,
122+
status: 403,
123+
message: 'Access denied',
124+
})
125+
126+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
127+
128+
expect(result.error).toEqual({ message: 'Access denied', status: 403 })
129+
})
130+
})

apps/sim/app/api/workflows/middleware.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ export async function validateWorkflowAccess(
5454
}
5555
}
5656

57+
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
58+
return {
59+
error: {
60+
message: 'API key is not authorized for this workspace',
61+
status: 403,
62+
},
63+
}
64+
}
65+
5766
const authorization = await authorizeWorkflowByWorkspacePermission({
5867
workflowId,
5968
userId: auth.userId,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 61 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,14 @@
11
'use client'
22

33
import type React from 'react'
4+
import { parse } from 'tldts'
45
import { Badge, Checkbox, Tooltip } from '@/components/emcn'
56
import { cn } from '@/lib/core/utils/cn'
67
import type { RowExecutionMetadata } from '@/lib/table'
78
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
89
import { storageToDisplay } from '../../../utils'
910
import type { DisplayColumn } from '../types'
1011

11-
/**
12-
* Discriminated union describing every shape a table cell can take.
13-
*
14-
* Workflow-output cells follow a status state machine: they always render
15-
* *something* (a value, a status pill, or a dash), driven by the combination
16-
* of `executions[groupId]` state and dep satisfaction. Plain (non-workflow)
17-
* cells just render the typed value or empty.
18-
*
19-
* `'empty'` is the universal fallback used by both workflow cells (no exec,
20-
* no value, no waiting) and plain cells (null/undefined value).
21-
*
22-
* Adding a new cell appearance is a three-step mechanical change: add a
23-
* variant here, pick it in `resolveCellRender`, render it in `CellRender`.
24-
* TypeScript's exhaustiveness check on the renderer's `switch` (the
25-
* unreachable default) flags any branch you forgot.
26-
*/
2712
export type CellRenderKind =
2813
// Workflow-output cells
2914
| { kind: 'value'; text: string }
@@ -38,6 +23,7 @@ export type CellRenderKind =
3823
| { kind: 'boolean'; checked: boolean }
3924
| { kind: 'json'; text: string }
4025
| { kind: 'date'; text: string }
26+
| { kind: 'url'; text: string; href: string; domain: string }
4127
| { kind: 'text'; text: string }
4228
// Universal fallback
4329
| { kind: 'empty' }
@@ -46,20 +32,9 @@ interface ResolveCellRenderInput {
4632
value: unknown
4733
exec: RowExecutionMetadata | undefined
4834
column: DisplayColumn
49-
/** Empty / undefined → not waiting; non-empty → render the Waiting pill. */
5035
waitingOnLabels: string[] | undefined
5136
}
5237

53-
/**
54-
* Decide which `CellRenderKind` to render for a cell. Pure — easily
55-
* unit-testable in isolation, no JSX involved.
56-
*
57-
* Order matters for workflow cells: block-error wins over a value (the user
58-
* cares about the failure), value wins over running/queued (we have data
59-
* already), and the running/queued branch deliberately collapses pre-enqueue
60-
* `pending` and post-enqueue `queued` into one `Queued` pill so the cell
61-
* doesn't flicker as the row transitions from one to the other.
62-
*/
6338
export function resolveCellRender({
6439
value,
6540
exec,
@@ -76,31 +51,20 @@ export function resolveCellRender({
7651

7752
if (blockError) return { kind: 'block-error' }
7853

79-
// Active re-run of THIS column wins over its prior value — the value is
80-
// about to be overwritten and the user should see the cell is changing.
8154
const inFlight =
8255
exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending'
8356
if (inFlight && blockRunning) return { kind: 'running' }
8457

85-
// Value wins over `pending-upstream`: once this column's output has
86-
// landed, the cell is done from the user's perspective — even if the
87-
// group is still running other blocks downstream. Without this, mid-run
88-
// partial-write events (`status: 'running'` carrying outputs but tagging
89-
// a different block as running) would flip a finished column back to the
90-
// amber Pending pill until the terminal `completed` event arrives.
58+
// Value wins over pending-upstream: a finished column stays finished even
59+
// while other blocks in the group are still running.
9160
if (!isNull) return { kind: 'value', text: stringifyValue(value) }
9261

9362
if (inFlight && !(groupHasBlockErrors && !blockRunning)) {
9463
if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' }
95-
// `running` with this block not in `runningBlockIds` and no value yet =
96-
// upstream block still going; surface as the amber Pending pill.
9764
return { kind: 'pending-upstream' }
9865
}
9966

100-
// Waiting wins over a stale terminal state: if deps are unmet right now,
101-
// the prior `cancelled` / `error` is informational at best — the cell
102-
// can't actually run until the user fills the missing input. Surface the
103-
// actionable state instead of the stale one.
67+
// Waiting wins over a stale terminal status — show the actionable state.
10468
if (waitingOnLabels && waitingOnLabels.length > 0) {
10569
return { kind: 'waiting', labels: waitingOnLabels }
10670
}
@@ -113,6 +77,12 @@ export function resolveCellRender({
11377
if (isNull) return { kind: 'empty' }
11478
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
11579
if (column.type === 'date') return { kind: 'date', text: String(value) }
80+
if (column.type === 'string') {
81+
const text = stringifyValue(value)
82+
const urlInfo = extractUrlInfo(text)
83+
if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain }
84+
return { kind: 'text', text }
85+
}
11686
return { kind: 'text', text: stringifyValue(value) }
11787
}
11888

@@ -122,19 +92,32 @@ function stringifyValue(value: unknown): string {
12292
return JSON.stringify(value)
12393
}
12494

95+
const BARE_DOMAIN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
96+
97+
function extractUrlInfo(text: string): { href: string; domain: string } | null {
98+
const trimmed = text.trim()
99+
if (!trimmed) return null
100+
if (/^https?:\/\//i.test(trimmed)) {
101+
try {
102+
const url = new URL(trimmed)
103+
return { href: trimmed, domain: url.hostname }
104+
} catch {
105+
return null
106+
}
107+
}
108+
if (BARE_DOMAIN_RE.test(trimmed)) {
109+
const parsed = parse(trimmed)
110+
if (!parsed.isIcann) return null
111+
return { href: `https://${trimmed}`, domain: trimmed }
112+
}
113+
return null
114+
}
115+
125116
interface CellRenderProps {
126117
kind: CellRenderKind
127-
/** When true the static content sits underneath the InlineEditor overlay
128-
* and should be visually hidden (but kept in flow to preserve cell size). */
129118
isEditing: boolean
130119
}
131120

132-
/**
133-
* Pure renderer: takes a `CellRenderKind` and returns the JSX. No business
134-
* logic — adding a new cell appearance means adding a new `case` here. The
135-
* exhaustiveness check on the `switch` (the unreachable default) flags any
136-
* variant you forgot to handle.
137-
*/
138121
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
139122
switch (kind.kind) {
140123
case 'value':
@@ -237,6 +220,35 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
237220
</span>
238221
)
239222

223+
case 'url':
224+
return (
225+
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
226+
<img
227+
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(kind.domain)}&sz=16`}
228+
alt=''
229+
width={12}
230+
height={12}
231+
className='shrink-0 rounded-[2px]'
232+
onError={(e) => {
233+
e.currentTarget.style.display = 'none'
234+
}}
235+
/>
236+
<a
237+
href={kind.href}
238+
target='_blank'
239+
rel='noopener noreferrer'
240+
className={cn(
241+
'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70',
242+
isEditing && 'pointer-events-none'
243+
)}
244+
onClick={(e) => e.stopPropagation()}
245+
onDoubleClick={(e) => e.stopPropagation()}
246+
>
247+
{kind.text}
248+
</a>
249+
</span>
250+
)
251+
240252
case 'text':
241253
return (
242254
<span
@@ -253,20 +265,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
253265
return null
254266

255267
default: {
256-
// Exhaustiveness guard: TypeScript flags this branch if a new
257-
// `CellRenderKind` variant is added without a matching `case` above.
258268
const _exhaustive: never = kind
259269
return _exhaustive
260270
}
261271
}
262272
}
263273

264-
/**
265-
* Workflow-output cells are hand-editable; while editing, the static content
266-
* must stay in flow (so the cell doesn't collapse) but be visually hidden so
267-
* the InlineEditor overlay shows through. Plain wrapper around any non-text
268-
* variant.
269-
*/
270274
function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) {
271275
if (!isEditing) return <>{children}</>
272276
return <div className='invisible'>{children}</div>

0 commit comments

Comments
 (0)