Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/sim/app/api/tools/onepassword/get-item-file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { onePasswordGetItemFileContract } from '@/lib/api/contracts/tools/onepassword'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectRequest,
createOnePasswordClient,
findItemFileAttributes,
resolveCredentials,
} from '../utils'

const logger = createLogger('OnePasswordGetItemFileAPI')

export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)

const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized 1Password get-item-file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

try {
const parsed = await parseRequest(
onePasswordGetItemFileContract,
request,
{},
{
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body
const creds = resolveCredentials(params)

logger.info(
`[${requestId}] Downloading file ${params.fileId} from item ${params.itemId} (${creds.mode} mode)`
)

if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const item = await client.items.get(params.vaultId, params.itemId)
const attr = findItemFileAttributes(item, params.fileId)
if (!attr) {
return NextResponse.json({ error: 'File not found on item' }, { status: 404 })
}

const content = await client.items.files.read(params.vaultId, params.itemId, attr)
return NextResponse.json({
file: {
name: attr.name,
mimeType: 'application/octet-stream',
data: Buffer.from(content).toString('base64'),
size: attr.size,
},
})
}

const metaResponse = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}`,
method: 'GET',
})
if (!metaResponse.ok) {
const metaData = await metaResponse.json().catch(() => ({}))
return NextResponse.json(
{ error: metaData.message || 'Failed to get file metadata' },
{ status: metaResponse.status }
)
}
const meta = await metaResponse.json()

const contentResponse = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}/content`,
method: 'GET',
})
if (!contentResponse.ok) {
const errorData = await contentResponse.json().catch(() => ({}))
return NextResponse.json(
{ error: errorData.message || 'Failed to download file content' },
{ status: contentResponse.status }
)
}

const buffer = Buffer.from(await contentResponse.arrayBuffer())
return NextResponse.json({
file: {
name: meta.name ?? 'attachment',
mimeType: contentResponse.headers.get('content-type') || 'application/octet-stream',
data: buffer.toString('base64'),
size: meta.size ?? buffer.length,
},
})
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Get item file failed:`, error)
return NextResponse.json({ error: `Failed to get item file: ${message}` }, { status: 500 })
}
})
9 changes: 4 additions & 5 deletions apps/sim/app/api/tools/onepassword/list-items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectRequest,
createOnePasswordClient,
matchesFilter,
normalizeSdkItemOverview,
resolveCredentials,
} from '../utils'
Expand Down Expand Up @@ -45,11 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const normalized = items.map(normalizeSdkItemOverview)

if (params.filter) {
const filterLower = params.filter.toLowerCase()
const filtered = normalized.filter(
(item) =>
item.title?.toLowerCase().includes(filterLower) ||
item.id?.toLowerCase().includes(filterLower)
const filter = params.filter
const filtered = normalized.filter((item) =>
matchesFilter(item.title ?? '', item.id ?? '', filter)
)
return NextResponse.json(filtered)
}
Expand Down
8 changes: 3 additions & 5 deletions apps/sim/app/api/tools/onepassword/list-vaults/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectRequest,
createOnePasswordClient,
matchesFilter,
normalizeSdkVault,
resolveCredentials,
} from '../utils'
Expand Down Expand Up @@ -45,11 +46,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const normalized = vaults.map(normalizeSdkVault)

if (params.filter) {
const filterLower = params.filter.toLowerCase()
const filtered = normalized.filter(
(v) =>
v.name?.toLowerCase().includes(filterLower) || v.id?.toLowerCase().includes(filterLower)
)
const filter = params.filter
const filtered = normalized.filter((v) => matchesFilter(v.name ?? '', v.id ?? '', filter))
return NextResponse.json(filtered)
}

Expand Down
39 changes: 2 additions & 37 deletions apps/sim/app/api/tools/onepassword/replace-item/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Item } from '@1password/sdk'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
Expand All @@ -8,12 +7,11 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
resolveCredentials,
toSdkCategory,
toSdkFieldType,
} from '../utils'

const logger = createLogger('OnePasswordReplaceItemAPI')
Expand Down Expand Up @@ -49,40 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const client = await createOnePasswordClient(creds.serviceAccountToken!)

const existing = await client.items.get(params.vaultId, params.itemId)

const sdkItem = {
...existing,
id: params.itemId,
title: itemData.title || existing.title,
category: itemData.category ? toSdkCategory(itemData.category) : existing.category,
vaultId: params.vaultId,
fields: itemData.fields
? (itemData.fields as Array<Record<string, any>>).map((f) => ({
id: f.id || generateId().slice(0, 8),
title: f.label || f.title || '',
fieldType: toSdkFieldType(f.type || 'STRING'),
value: f.value || '',
sectionId: f.section?.id ?? f.sectionId,
}))
: existing.fields,
sections: itemData.sections
? (itemData.sections as Array<Record<string, any>>).map((s) => ({
id: s.id || '',
title: s.label || s.title || '',
}))
: existing.sections,
notes: itemData.notes ?? existing.notes,
tags: itemData.tags ?? existing.tags,
websites:
itemData.urls || itemData.websites
? (itemData.urls ?? itemData.websites ?? []).map((u: Record<string, any>) => ({
url: u.href || u.url || '',
label: u.label || '',
autofillBehavior: 'AnywhereOnWebsite' as const,
}))
: existing.websites,
} as Item

const sdkItem = connectItemToSdkItem(itemData, existing)
const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}
Expand Down
43 changes: 37 additions & 6 deletions apps/sim/app/api/tools/onepassword/update-item/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
Expand Down Expand Up @@ -45,13 +46,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)

const item = await client.items.get(params.vaultId, params.itemId)
const existing = await client.items.get(params.vaultId, params.itemId)

// Patch operations are documented and typed against the Connect-shaped
// vocabulary (label/type/section.id) that get_item/create_item/replace_item
// return — apply them to that normalized view, then convert back to the
// SDK's vocabulary (title/fieldType/sectionId) before writing. Patching the
// raw SDK item directly would silently no-op most field/category writes.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const connectItem = normalizeSdkItem(existing) as Record<string, any>
for (const op of ops) {
applyPatch(item, op)
applyPatch(connectItem, op)
}

const result = await client.items.put(item)
const sdkItem = connectItemToSdkItem(connectItem, existing)
const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}

Expand Down Expand Up @@ -104,7 +113,7 @@ function applyPatch(item: Record<string, any>, op: JsonPatchOperation) {
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i]
if (Array.isArray(target)) {
target = target[Number(seg)]
target = arrayElementForSegment(target, seg)
} else {
target = target[seg]
}
Expand All @@ -117,15 +126,37 @@ function applyPatch(item: Record<string, any>, op: JsonPatchOperation) {
if (Array.isArray(target) && lastSeg === '-') {
target.push(op.value)
} else if (Array.isArray(target)) {
target[Number(lastSeg)] = op.value
const index = arrayIndexForSegment(target, lastSeg)
if (index !== -1) target[index] = op.value
} else {
target[lastSeg] = op.value
}
} else if (op.op === 'remove') {
if (Array.isArray(target)) {
target.splice(Number(lastSeg), 1)
const index = arrayIndexForSegment(target, lastSeg)
if (index !== -1) target.splice(index, 1)
} else {
delete target[lastSeg]
}
}
}

/**
* Resolves an array element for a JSON Patch path segment. 1Password's PATCH API
* addresses items in the `fields`/`sections` arrays by their `id`, not by numeric
* array index (e.g. `/fields/{fieldId}/value`), so a numeric-looking segment is
* only treated as a literal index when no element's `id` matches it.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function arrayIndexForSegment(target: any[], segment: string): number {
const byId = target.findIndex((el) => el && typeof el === 'object' && el.id === segment)
if (byId !== -1) return byId
const index = Number(segment)
return Number.isInteger(index) && index >= 0 && index < target.length ? index : -1
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function arrayElementForSegment(target: any[], segment: string): any {
const index = arrayIndexForSegment(target, segment)
return index === -1 ? undefined : target[index]
}
Loading
Loading