diff --git a/apps/sim/app/api/tools/onepassword/get-item-file/route.ts b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts new file mode 100644 index 00000000000..65efec88e06 --- /dev/null +++ b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts @@ -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 }) + } +}) diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts index daeb42e807d..395d0955ced 100644 --- a/apps/sim/app/api/tools/onepassword/list-items/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkItemOverview, resolveCredentials, } from '../utils' @@ -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) } diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts index fa4011daa70..3638db1c2d7 100644 --- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkVault, resolveCredentials, } from '../utils' @@ -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) } diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts index 0f2ee44b76b..67d7a7b10f8 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -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' @@ -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') @@ -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>).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>).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) => ({ - 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)) } diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts index f027e95c45d..70fa927b992 100644 --- a/apps/sim/app/api/tools/onepassword/update-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts @@ -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, @@ -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 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)) } @@ -104,7 +113,7 @@ function applyPatch(item: Record, 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] } @@ -117,15 +126,37 @@ function applyPatch(item: Record, 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] +} diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index 87c5e090da0..b78cf0d511c 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -1,9 +1,11 @@ import dns from 'dns/promises' import type { + FileAttributes, Item, ItemCategory, ItemField, ItemFieldType, + ItemFile, ItemOverview, ItemSection, VaultOverview, @@ -11,6 +13,7 @@ import type { } from '@1password/sdk' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' import { @@ -102,10 +105,19 @@ interface NormalizedField { entropy: null } +/** Normalized attached-file metadata shape matching the Connect API response. */ +export interface NormalizedItemFile { + id: string + name: string + size: number + section: { id: string } | null +} + /** Normalized full item shape matching the Connect API response. */ export interface NormalizedItem extends NormalizedItemOverview { fields: NormalizedField[] sections: Array<{ id: string; label: string }> + files: NormalizedItemFile[] } /** @@ -323,9 +335,11 @@ export interface ConnectResponse { ok: boolean status: number statusText: string + headers: { get: (name: string) => string | null } // eslint-disable-next-line @typescript-eslint/no-explicit-any json: () => Promise text: () => Promise + arrayBuffer: () => Promise } /** Proxy a request to the 1Password Connect Server. */ @@ -431,6 +445,24 @@ export function normalizeSdkItem(item: Item): NormalizedItem { id: section.id, label: section.title, })), + files: [ + ...(item.files ?? []).map((file: ItemFile) => ({ + id: file.attributes.id, + name: file.attributes.name, + size: file.attributes.size, + section: file.sectionId ? { id: file.sectionId } : null, + })), + ...(item.document + ? [ + { + id: item.document.id, + name: item.document.name, + size: item.document.size, + section: null, + }, + ] + : []), + ], createdAt: item.createdAt instanceof Date ? item.createdAt.toISOString() : (item.createdAt ?? null), updatedAt: @@ -439,6 +471,98 @@ export function normalizeSdkItem(item: Item): NormalizedItem { } } +/** + * Find an attached file's SDK {@link FileAttributes} on an item by file ID. + * Checks both the `files` array and the single `document` attribute that + * Document-category items carry instead of a `files` entry. + */ +export function findItemFileAttributes(item: Item, fileId: string): FileAttributes | undefined { + if (item.document?.id === fileId) return item.document + return item.files?.find((file) => file.attributes.id === fileId)?.attributes +} + +/** + * Convert a Connect-shaped item (the vocabulary `normalizeSdkItem` produces and + * this integration's tools document — `label`/`type`/`section: {id}`) back into + * an SDK-compatible {@link Item} for `client.items.put()`. Falls back to `existing` + * for any array the caller didn't provide, so partial input (e.g. Replace Item's + * optional fields) is preserved. + * + * Service Account mode must always convert through this function before calling + * `put()` — never apply a Connect-shaped JSON Patch directly onto a raw SDK + * {@link Item}, since SDK field/category vocabulary differs from Connect's + * (`title` vs `label`, `fieldType` vs `type`, `sectionId` vs `section.id`, SDK + * category enum strings vs Connect's SCREAMING_SNAKE_CASE) and silently no-ops or + * corrupts the write otherwise. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function connectItemToSdkItem(connectItem: Record, existing: Item): Item { + const existingFieldsById = new Map((existing.fields ?? []).map((f) => [f.id, f])) + const existingSectionsById = new Map((existing.sections ?? []).map((s) => [s.id, s])) + + return { + ...existing, + id: existing.id, + vaultId: existing.vaultId, + title: connectItem.title || existing.title, + category: connectItem.category ? toSdkCategory(connectItem.category) : existing.category, + fields: Array.isArray(connectItem.fields) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.fields.map((f: Record) => ({ + // Preserve any SDK-only metadata (e.g. password-generation `details`) + // on fields that already existed — only brand-new fields start bare. + ...(f.id ? existingFieldsById.get(f.id) : undefined), + 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: Array.isArray(connectItem.sections) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.sections.map((s: Record) => ({ + ...(s.id ? existingSectionsById.get(s.id) : undefined), + id: s.id || '', + title: s.label || s.title || '', + })) + : existing.sections, + notes: connectItem.notes ?? existing.notes, + tags: connectItem.tags ?? existing.tags, + websites: Array.isArray(connectItem.urls ?? connectItem.websites) + ? (connectItem.urls ?? connectItem.websites).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (u: Record) => ({ + url: u.href || u.url || '', + label: u.label || '', + autofillBehavior: 'AnywhereOnWebsite' as const, + }) + ) + : existing.websites, + } as Item +} + +/** + * Best-effort SCIM `eq` filter matcher for Service Account mode, which has no + * server-side filtering (unlike Connect, whose `filter` query param is forwarded + * verbatim and evaluated by the Connect server). Recognizes `attribute eq "value"` + * (quotes optional) as an exact, case-insensitive match against the named attribute + * — `id` compares against the id, anything else (name/title/etc.) against the + * display value; anything that doesn't parse as `eq` falls back to a + * case-insensitive substring match against both so the field remains useful for + * free-text search. + */ +export function matchesFilter(value: string, id: string, filter: string): boolean { + const eqMatch = filter.match(/^\s*(\S+)\s+eq\s+"?([^"]*)"?\s*$/i) + if (eqMatch) { + const [, attribute, needle] = eqMatch + const target = attribute.toLowerCase() === 'id' ? id : value + return target.toLowerCase() === needle.toLowerCase() + } + const needle = filter.toLowerCase() + return value.toLowerCase().includes(needle) || id.toLowerCase().includes(needle) +} + /** Convert a Connect-style category string to the SDK category string. */ export function toSdkCategory(category: string): `${ItemCategory}` { return CONNECT_TO_SDK_CATEGORY[category] ?? 'Login' diff --git a/apps/sim/blocks/blocks/onepassword.ts b/apps/sim/blocks/blocks/onepassword.ts index c5e9b61271d..084ef242d92 100644 --- a/apps/sim/blocks/blocks/onepassword.ts +++ b/apps/sim/blocks/blocks/onepassword.ts @@ -6,7 +6,7 @@ export const OnePasswordBlock: BlockConfig = { name: '1Password', description: 'Manage secrets and items in 1Password vaults', longDescription: - 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.', + 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.', docsLink: 'https://docs.sim.ai/integrations/onepassword', category: 'tools', integrationType: IntegrationType.Security, @@ -24,6 +24,7 @@ export const OnePasswordBlock: BlockConfig = { { label: 'Get Vault', id: 'get_vault' }, { label: 'List Items', id: 'list_items' }, { label: 'Get Item', id: 'get_item' }, + { label: 'Get Item File', id: 'get_item_file' }, { label: 'Create Item', id: 'create_item' }, { label: 'Replace Item', id: 'replace_item' }, { label: 'Update Item', id: 'update_item' }, @@ -56,8 +57,16 @@ export const OnePasswordBlock: BlockConfig = { title: 'Server URL', type: 'short-input', placeholder: 'http://localhost:8080', - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'apiKey', @@ -65,8 +74,16 @@ export const OnePasswordBlock: BlockConfig = { type: 'short-input', placeholder: 'Enter your 1Password Connect token', password: true, - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'secretReference', @@ -93,13 +110,13 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, title: 'Vault ID', type: 'short-input', placeholder: 'Enter vault UUID', - password: true, required: { field: 'operation', value: [ 'get_vault', 'list_items', 'get_item', + 'get_item_file', 'create_item', 'replace_item', 'update_item', @@ -119,19 +136,38 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, placeholder: 'Enter item UUID', required: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, condition: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, }, + { + id: 'fileId', + title: 'File ID', + type: 'short-input', + placeholder: 'Enter file ID (from Get Item output)', + required: { field: 'operation', value: 'get_item_file' }, + condition: { field: 'operation', value: 'get_item_file' }, + }, { id: 'filter', title: 'Filter', type: 'short-input', placeholder: 'SCIM filter (e.g., name eq "My Vault")', condition: { field: 'operation', value: ['list_vaults', 'list_items'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a SCIM filter expression for a 1Password vault or item list based on the user's description. +Examples: +- name eq "My Vault" +- title eq "API Key" +- tag eq "production" + +Return ONLY the SCIM filter expression - no explanations, no quotes, no markdown.`, + }, }, { id: 'category', @@ -147,6 +183,17 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, { label: 'Credit Card', id: 'CREDIT_CARD' }, { label: 'Identity', id: 'IDENTITY' }, { label: 'SSH Key', id: 'SSH_KEY' }, + { label: 'Software License', id: 'SOFTWARE_LICENSE' }, + { label: 'Email Account', id: 'EMAIL_ACCOUNT' }, + { label: 'Membership', id: 'MEMBERSHIP' }, + { label: 'Passport', id: 'PASSPORT' }, + { label: 'Reward Program', id: 'REWARD_PROGRAM' }, + { label: 'Driver License', id: 'DRIVER_LICENSE' }, + { label: 'Bank Account', id: 'BANK_ACCOUNT' }, + { label: 'Medical Record', id: 'MEDICAL_RECORD' }, + { label: 'Outdoor License', id: 'OUTDOOR_LICENSE' }, + { label: 'Wireless Router', id: 'WIRELESS_ROUTER' }, + { label: 'Social Security Number', id: 'SOCIAL_SECURITY_NUMBER' }, ], value: () => 'LOGIN', required: { field: 'operation', value: 'create_item' }, @@ -231,6 +278,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, 'onepassword_get_vault', 'onepassword_list_items', 'onepassword_get_item', + 'onepassword_get_item_file', 'onepassword_create_item', 'onepassword_replace_item', 'onepassword_update_item', @@ -251,6 +299,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, secretReference: { type: 'string', description: 'Secret reference URI (op://...)' }, vaultId: { type: 'string', description: 'Vault UUID' }, itemId: { type: 'string', description: 'Item UUID' }, + fileId: { type: 'string', description: 'File ID of an attachment on the item' }, filter: { type: 'string', description: 'SCIM filter expression' }, category: { type: 'string', description: 'Item category' }, title: { type: 'string', description: 'Item title' }, @@ -263,7 +312,176 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, outputs: { response: { type: 'json', - description: 'Operation response data', + description: + 'Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead.', + }, + vaults: { + type: 'json', + description: + 'List of accessible vaults [{id, name, description, items, type, createdAt, updatedAt}]', + condition: { field: 'operation', value: 'list_vaults' }, + }, + id: { + type: 'string', + description: 'Vault or item ID', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + name: { + type: 'string', + description: 'Vault name', + condition: { field: 'operation', value: 'get_vault' }, + }, + description: { + type: 'string', + description: 'Vault description', + condition: { field: 'operation', value: 'get_vault' }, + }, + items: { + type: 'json', + description: + 'Number of items in the vault (Get Vault) or item summaries [{id, title, category, tags, favorite, version, updatedAt}] (List Items)', + condition: { field: 'operation', value: ['get_vault', 'list_items'] }, + }, + type: { + type: 'string', + description: 'Vault type (USER_CREATED, PERSONAL, or EVERYONE)', + condition: { field: 'operation', value: 'get_vault' }, + }, + title: { + type: 'string', + description: 'Item title', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + category: { + type: 'string', + description: 'Item category (e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + vault: { + type: 'json', + description: 'Vault reference the item belongs to {id}', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + fields: { + type: 'json', + description: 'Item fields including secrets [{id, label, type, purpose, value}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + sections: { + type: 'json', + description: 'Item sections [{id, label}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + files: { + type: 'json', + description: + 'Files attached to the item [{id, name, size, section}] — fetch content with Get Item File', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + tags: { + type: 'json', + description: 'Item tags', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + urls: { + type: 'json', + description: 'URLs associated with the item [{href, label, primary}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + favorite: { + type: 'boolean', + description: 'Whether the item is favorited', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + version: { + type: 'number', + description: 'Item version number', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + state: { + type: 'string', + description: 'Item state (ARCHIVED, or absent/null when active)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + lastEditedBy: { + type: 'string', + description: 'ID of the last editor', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + createdAt: { + type: 'string', + description: 'Creation timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + updatedAt: { + type: 'string', + description: 'Last update timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + success: { + type: 'boolean', + description: 'Whether the item was successfully deleted', + condition: { field: 'operation', value: 'delete_item' }, + }, + value: { + type: 'string', + description: 'The resolved secret value', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + reference: { + type: 'string', + description: 'The original secret reference URI', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + file: { + type: 'file', + description: 'Downloaded file attachment', + condition: { field: 'operation', value: 'get_item_file' }, }, }, } diff --git a/apps/sim/lib/api/contracts/tools/onepassword.ts b/apps/sim/lib/api/contracts/tools/onepassword.ts index b67c7fe12e1..349b13296f9 100644 --- a/apps/sim/lib/api/contracts/tools/onepassword.ts +++ b/apps/sim/lib/api/contracts/tools/onepassword.ts @@ -43,6 +43,10 @@ export const onePasswordResolveSecretBodySchema = onePasswordCredentialsBodySche secretReference: z.string().min(1, 'Secret reference is required'), }) +export const onePasswordGetItemFileBodySchema = onePasswordGetItemBodySchema.extend({ + fileId: z.string().min(1, 'File ID is required'), +}) + export const onePasswordListVaultsContract = defineRouteContract({ method: 'POST', path: '/api/tools/onepassword/list-vaults', @@ -148,3 +152,22 @@ export const onePasswordResolveSecretContract = defineRouteContract({ schema: onePasswordResolveSecretResponseSchema, }, }) + +const onePasswordGetItemFileResponseSchema = z.object({ + file: z.object({ + name: z.string(), + mimeType: z.string(), + data: z.string(), + size: z.number(), + }), +}) + +export const onePasswordGetItemFileContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/onepassword/get-item-file', + body: onePasswordGetItemFileBodySchema, + response: { + mode: 'json', + schema: onePasswordGetItemFileResponseSchema, + }, +}) diff --git a/apps/sim/tools/onepassword/create_item.ts b/apps/sim/tools/onepassword/create_item.ts index 5f9b70a0715..feb717f3af2 100644 --- a/apps/sim/tools/onepassword/create_item.ts +++ b/apps/sim/tools/onepassword/create_item.ts @@ -68,7 +68,7 @@ export const createItemTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}])', + 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}]). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead.', }, }, diff --git a/apps/sim/tools/onepassword/get_item_file.ts b/apps/sim/tools/onepassword/get_item_file.ts new file mode 100644 index 00000000000..f5934886f4f --- /dev/null +++ b/apps/sim/tools/onepassword/get_item_file.ts @@ -0,0 +1,103 @@ +import type { + OnePasswordGetItemFileParams, + OnePasswordGetItemFileResponse, +} from '@/tools/onepassword/types' +import type { ToolConfig } from '@/tools/types' + +export const getItemFileTool: ToolConfig< + OnePasswordGetItemFileParams, + OnePasswordGetItemFileResponse +> = { + id: 'onepassword_get_item_file', + name: '1Password Get Item File', + description: 'Download the content of a file attached to an item', + version: '1.0.0', + + params: { + connectionMode: { + type: 'string', + required: false, + description: 'Connection mode: "service_account" or "connect"', + }, + serviceAccountToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Service Account token (for Service Account mode)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Connect API token (for Connect Server mode)', + }, + serverUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Connect server URL (for Connect Server mode)', + }, + vaultId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The vault UUID', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The item UUID the file is attached to', + }, + fileId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The file ID (from the item\'s "files" array, e.g. via Get Item)', + }, + }, + + request: { + url: '/api/tools/onepassword/get-item-file', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + connectionMode: params.connectionMode, + serviceAccountToken: params.serviceAccountToken, + serverUrl: params.serverUrl, + apiKey: params.apiKey, + vaultId: params.vaultId, + itemId: params.itemId, + fileId: params.fileId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (data.error) { + return { + success: false, + output: { file: { name: '', mimeType: '', data: '', size: 0 } }, + error: data.error, + } + } + return { + success: true, + output: { + file: { + name: data.file.name, + mimeType: data.file.mimeType, + data: data.file.data, + size: data.file.size, + }, + }, + } + }, + + outputs: { + file: { + type: 'file', + description: 'Downloaded file attachment', + }, + }, +} diff --git a/apps/sim/tools/onepassword/get_vault.ts b/apps/sim/tools/onepassword/get_vault.ts index cf2b63a4479..30415c78363 100644 --- a/apps/sim/tools/onepassword/get_vault.ts +++ b/apps/sim/tools/onepassword/get_vault.ts @@ -99,7 +99,7 @@ export const getVaultTool: ToolConfig + files: Array<{ + id: string | null + name: string | null + size: number + section: { id: string } | null + }> createdAt: string | null updatedAt: string | null lastEditedBy: string | null @@ -151,6 +163,17 @@ export interface OnePasswordDeleteItemResponse extends ToolResponse { } } +export interface OnePasswordGetItemFileResponse extends ToolResponse { + output: { + file: { + name: string + mimeType: string + data: string + size: number + } + } +} + export interface OnePasswordResolveSecretResponse extends ToolResponse { output: { value: string diff --git a/apps/sim/tools/onepassword/utils.ts b/apps/sim/tools/onepassword/utils.ts index d1abbf9632c..fad1f8fd555 100644 --- a/apps/sim/tools/onepassword/utils.ts +++ b/apps/sim/tools/onepassword/utils.ts @@ -37,6 +37,12 @@ export function transformFullItem(data: any) { id: section.id ?? null, label: section.label ?? null, })), + files: (data.files ?? []).map((file: any) => ({ + id: file.id ?? null, + name: file.name ?? null, + size: file.size ?? 0, + section: file.section ?? null, + })), createdAt: data.createdAt ?? null, updatedAt: data.updatedAt ?? null, lastEditedBy: data.lastEditedBy ?? null, @@ -83,7 +89,11 @@ export const FULL_ITEM_OUTPUTS: Record< favorite: { type: 'boolean', description: 'Whether the item is favorited' }, tags: { type: 'array', description: 'Item tags' }, version: { type: 'number', description: 'Item version number' }, - state: { type: 'string', description: 'Item state (ARCHIVED or DELETED)', optional: true }, + state: { + type: 'string', + description: 'Item state (ARCHIVED, or absent/null when active)', + optional: true, + }, fields: { type: 'array', description: 'Item fields including secrets', @@ -142,6 +152,26 @@ export const FULL_ITEM_OUTPUTS: Record< }, }, }, + files: { + type: 'array', + description: 'Files attached to the item (fetch content with Get Item File)', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + section: { + type: 'object', + description: 'Section reference this file belongs to', + optional: true, + properties: { + id: { type: 'string', description: 'Section ID' }, + }, + }, + }, + }, + }, createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, updatedAt: { type: 'string', description: 'Last update timestamp', optional: true }, lastEditedBy: { type: 'string', description: 'ID of the last editor', optional: true }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2bb910c4eef..e25a92dd960 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2394,6 +2394,7 @@ import { import { onepasswordCreateItemTool, onepasswordDeleteItemTool, + onepasswordGetItemFileTool, onepasswordGetItemTool, onepasswordGetVaultTool, onepasswordListItemsTool, @@ -5333,6 +5334,7 @@ export const tools: Record = { onepassword_get_vault: onepasswordGetVaultTool, onepassword_list_items: onepasswordListItemsTool, onepassword_get_item: onepasswordGetItemTool, + onepassword_get_item_file: onepasswordGetItemFileTool, onepassword_create_item: onepasswordCreateItemTool, onepassword_replace_item: onepasswordReplaceItemTool, onepassword_update_item: onepasswordUpdateItemTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0d0d94230aa..80f935786b0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 882, - zodRoutes: 882, + totalRoutes: 883, + zodRoutes: 883, nonZodRoutes: 0, } as const