chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,106 @@
import type { ItemCreateParams } from '@1password/sdk'
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 { onePasswordCreateItemContract } 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,
normalizeSdkItem,
resolveCredentials,
toSdkCategory,
toSdkFieldType,
} from '../utils'
const logger = createLogger('OnePasswordCreateItemAPI')
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 create-item attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordCreateItemContract,
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}] Creating item in vault ${params.vaultId} (${creds.mode} mode)`)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const parsedTags = params.tags
? params.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
: undefined
const parsedFields = params.fields
? (JSON.parse(params.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,
}))
: undefined
const item = await client.items.create({
vaultId: params.vaultId,
category: toSdkCategory(params.category),
title: params.title || '',
tags: parsedTags,
fields: parsedFields,
} as ItemCreateParams)
return NextResponse.json(normalizeSdkItem(item))
}
const connectBody: Record<string, unknown> = {
vault: { id: params.vaultId },
category: params.category,
}
if (params.title) connectBody.title = params.title
if (params.tags) connectBody.tags = params.tags.split(',').map((t) => t.trim())
if (params.fields) connectBody.fields = JSON.parse(params.fields)
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items`,
method: 'POST',
body: connectBody,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to create item' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Create item failed:`, error)
return NextResponse.json({ error: `Failed to create item: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,66 @@
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 { onePasswordDeleteItemContract } 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, resolveCredentials } from '../utils'
const logger = createLogger('OnePasswordDeleteItemAPI')
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 delete-item attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordDeleteItemContract,
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}] Deleting item ${params.itemId} from vault ${params.vaultId} (${creds.mode} mode)`
)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
await client.items.delete(params.vaultId, params.itemId)
return NextResponse.json({ success: true })
}
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}`,
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
return NextResponse.json(
{ error: (data as Record<string, string>).message || 'Failed to delete item' },
{ status: response.status }
)
}
return NextResponse.json({ success: true })
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Delete item failed:`, error)
return NextResponse.json({ error: `Failed to delete item: ${message}` }, { status: 500 })
}
})
@@ -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 })
}
})
@@ -0,0 +1,71 @@
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 { onePasswordGetItemContract } 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,
normalizeSdkItem,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordGetItemAPI')
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 attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordGetItemContract,
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}] Getting item ${params.itemId} from vault ${params.vaultId} (${creds.mode} mode)`
)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const item = await client.items.get(params.vaultId, params.itemId)
return NextResponse.json(normalizeSdkItem(item))
}
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}`,
method: 'GET',
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to get item' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Get item failed:`, error)
return NextResponse.json({ error: `Failed to get item: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,75 @@
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 { onePasswordGetVaultContract } 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,
normalizeSdkVault,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordGetVaultAPI')
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-vault attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordGetVaultContract,
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}] Getting 1Password vault ${params.vaultId} (${creds.mode} mode)`)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const vaults = await client.vaults.list()
const vault = vaults.find((v) => v.id === params.vaultId)
if (!vault) {
return NextResponse.json({ error: 'Vault not found' }, { status: 404 })
}
return NextResponse.json(normalizeSdkVault(vault))
}
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}`,
method: 'GET',
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to get vault' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Get vault failed:`, error)
return NextResponse.json({ error: `Failed to get vault: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,82 @@
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 { onePasswordListItemsContract } 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,
matchesFilter,
normalizeSdkItemOverview,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordListItemsAPI')
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 list-items attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordListItemsContract,
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}] Listing items in vault ${params.vaultId} (${creds.mode} mode)`)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const items = await client.items.list(params.vaultId)
const normalized = items.map(normalizeSdkItemOverview)
if (params.filter) {
const filter = params.filter
const filtered = normalized.filter((item) =>
matchesFilter(item.title ?? '', item.id ?? '', filter)
)
return NextResponse.json(filtered)
}
return NextResponse.json(normalized)
}
const query = params.filter ? `filter=${encodeURIComponent(params.filter)}` : undefined
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items`,
method: 'GET',
query,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to list items' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] List items failed:`, error)
return NextResponse.json({ error: `Failed to list items: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,80 @@
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 { onePasswordListVaultsContract } 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,
matchesFilter,
normalizeSdkVault,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordListVaultsAPI')
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 list-vaults attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordListVaultsContract,
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}] Listing 1Password vaults (${creds.mode} mode)`)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const vaults = await client.vaults.list()
const normalized = vaults.map(normalizeSdkVault)
if (params.filter) {
const filter = params.filter
const filtered = normalized.filter((v) => matchesFilter(v.name ?? '', v.id ?? '', filter))
return NextResponse.json(filtered)
}
return NextResponse.json(normalized)
}
const query = params.filter ? `filter=${encodeURIComponent(params.filter)}` : undefined
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: '/v1/vaults',
method: 'GET',
query,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to list vaults' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] List vaults failed:`, error)
return NextResponse.json({ error: `Failed to list vaults: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,77 @@
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 { onePasswordReplaceItemContract } 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 {
connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordReplaceItemAPI')
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 replace-item attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordReplaceItemContract,
request,
{},
{
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body
const creds = resolveCredentials(params)
const itemData = JSON.parse(params.item)
logger.info(
`[${requestId}] Replacing item ${params.itemId} in vault ${params.vaultId} (${creds.mode} mode)`
)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const existing = await client.items.get(params.vaultId, params.itemId)
const sdkItem = connectItemToSdkItem(itemData, existing)
const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}`,
method: 'PUT',
body: itemData,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to replace item' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Replace item failed:`, error)
return NextResponse.json({ error: `Failed to replace item: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,56 @@
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 { onePasswordResolveSecretContract } 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 { createOnePasswordClient, resolveCredentials } from '../utils'
const logger = createLogger('OnePasswordResolveSecretAPI')
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 resolve-secret attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordResolveSecretContract,
request,
{},
{
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body
const creds = resolveCredentials(params)
if (creds.mode !== 'service_account') {
return NextResponse.json(
{ error: 'Resolve Secret is only available in Service Account mode' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Resolving secret reference (service_account mode)`)
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const secret = await client.secrets.resolve(params.secretReference)
return NextResponse.json({
value: secret,
reference: params.secretReference,
})
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Resolve secret failed:`, error)
return NextResponse.json({ error: `Failed to resolve secret: ${message}` }, { status: 500 })
}
})
@@ -0,0 +1,162 @@
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 { onePasswordUpdateItemContract } 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 {
connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
resolveCredentials,
} from '../utils'
const logger = createLogger('OnePasswordUpdateItemAPI')
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 update-item attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try {
const parsed = await parseRequest(
onePasswordUpdateItemContract,
request,
{},
{
validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.body
const creds = resolveCredentials(params)
const ops = JSON.parse(params.operations) as JsonPatchOperation[]
logger.info(
`[${requestId}] Updating item ${params.itemId} in vault ${params.vaultId} (${creds.mode} mode)`
)
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
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(connectItem, op)
}
const sdkItem = connectItemToSdkItem(connectItem, existing)
const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}
const response = await connectRequest({
serverUrl: creds.serverUrl!,
apiKey: creds.apiKey!,
path: `/v1/vaults/${params.vaultId}/items/${params.itemId}`,
method: 'PATCH',
body: ops,
})
const data = await response.json()
if (!response.ok) {
return NextResponse.json(
{ error: data.message || 'Failed to update item' },
{ status: response.status }
)
}
return NextResponse.json(data)
} catch (error) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Update item failed:`, error)
return NextResponse.json({ error: `Failed to update item: ${message}` }, { status: 500 })
}
})
interface JsonPatchOperation {
op: 'add' | 'remove' | 'replace'
path: string
value?: unknown
}
/** Apply a single RFC6902 JSON Patch operation to a mutable object. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyPatch(item: Record<string, any>, op: JsonPatchOperation) {
const segments = op.path.split('/').filter(Boolean)
if (segments.length === 1) {
const key = segments[0]
if (op.op === 'replace' || op.op === 'add') {
item[key] = op.value
} else if (op.op === 'remove') {
delete item[key]
}
return
}
let target = item
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i]
if (Array.isArray(target)) {
target = arrayElementForSegment(target, seg)
} else {
target = target[seg]
}
if (target === undefined || target === null) return
}
const lastSeg = segments[segments.length - 1]
if (op.op === 'replace' || op.op === 'add') {
if (Array.isArray(target) && lastSeg === '-') {
target.push(op.value)
} else if (Array.isArray(target)) {
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)) {
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]
}
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDnsLookup, hostedFlag } = vi.hoisted(() => ({
mockDnsLookup: vi.fn(),
hostedFlag: { value: false },
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isHosted() {
return hostedFlag.value
},
}))
vi.mock('dns/promises', () => ({
default: { lookup: mockDnsLookup },
}))
import { validateConnectServerUrl } from '@/app/api/tools/onepassword/utils'
describe('validateConnectServerUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
hostedFlag.value = false
})
it('rejects a non-URL string', async () => {
await expect(validateConnectServerUrl('not a url')).rejects.toThrow('is not a valid URL')
})
describe('hosted deployment', () => {
beforeEach(() => {
hostedFlag.value = true
})
it.each([
['loopback', 'http://127.0.0.1:8080'],
['RFC1918 10.x', 'http://10.0.0.5'],
['RFC1918 192.168.x', 'http://192.168.1.1:8443'],
['RFC1918 172.16.x', 'http://172.16.0.9'],
['link-local metadata', 'http://169.254.169.254'],
['IPv4-mapped IPv6 private', 'http://[::ffff:10.0.0.1]'],
['IPv6 loopback', 'http://[::1]'],
])('blocks %s', async (_label, url) => {
await expect(validateConnectServerUrl(url)).rejects.toThrow(
'cannot point to a private or reserved IP address'
)
})
it('allows a public IP literal', async () => {
await expect(validateConnectServerUrl('https://8.8.8.8')).resolves.toBe('8.8.8.8')
})
it('blocks a hostname that resolves to a private IP', async () => {
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
await expect(validateConnectServerUrl('https://connect.internal')).rejects.toThrow(
'cannot point to a private or reserved IP address'
)
})
it('allows a hostname that resolves to a public IP', async () => {
mockDnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 })
await expect(validateConnectServerUrl('https://connect.example.com')).resolves.toBe(
'93.184.216.34'
)
})
})
describe('self-hosted deployment', () => {
beforeEach(() => {
hostedFlag.value = false
})
it.each([
['loopback', 'http://127.0.0.1:8080', '127.0.0.1'],
['RFC1918 10.x', 'http://10.0.0.5', '10.0.0.5'],
['RFC1918 192.168.x', 'http://192.168.1.1:8443', '192.168.1.1'],
])('allows %s (private Connect server)', async (_label, url, expected) => {
await expect(validateConnectServerUrl(url)).resolves.toBe(expected)
})
it('still blocks link-local metadata', async () => {
await expect(validateConnectServerUrl('http://169.254.169.254')).rejects.toThrow(
'cannot point to a link-local address'
)
})
it('still blocks IPv6 link-local', async () => {
await expect(validateConnectServerUrl('http://[fe80::1]')).rejects.toThrow(
'cannot point to a link-local address'
)
})
it('allows a hostname that resolves to a private IP', async () => {
mockDnsLookup.mockResolvedValue({ address: '10.1.2.3', family: 4 })
await expect(validateConnectServerUrl('https://connect.internal')).resolves.toBe('10.1.2.3')
})
})
it('rejects when DNS resolution fails', async () => {
mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'))
await expect(validateConnectServerUrl('https://nope.invalid')).rejects.toThrow(
'could not be resolved'
)
})
})
+574
View File
@@ -0,0 +1,574 @@
import dns from 'dns/promises'
import type {
FileAttributes,
Item,
ItemCategory,
ItemField,
ItemFieldType,
ItemFile,
ItemOverview,
ItemSection,
VaultOverview,
Website,
} 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 {
isPrivateOrReservedIP,
secureFetchWithPinnedIP,
} from '@/lib/core/security/input-validation.server'
/** Connect-format field type strings returned by normalization. */
type ConnectFieldType =
| 'STRING'
| 'CONCEALED'
| 'EMAIL'
| 'URL'
| 'OTP'
| 'PHONE'
| 'DATE'
| 'MONTH_YEAR'
| 'MENU'
| 'ADDRESS'
| 'REFERENCE'
| 'SSHKEY'
| 'CREDIT_CARD_NUMBER'
| 'CREDIT_CARD_TYPE'
/** Connect-format category strings returned by normalization. */
type ConnectCategory =
| 'LOGIN'
| 'PASSWORD'
| 'API_CREDENTIAL'
| 'SECURE_NOTE'
| 'SERVER'
| 'DATABASE'
| 'CREDIT_CARD'
| 'IDENTITY'
| 'SSH_KEY'
| 'DOCUMENT'
| 'SOFTWARE_LICENSE'
| 'EMAIL_ACCOUNT'
| 'MEMBERSHIP'
| 'PASSPORT'
| 'REWARD_PROGRAM'
| 'DRIVER_LICENSE'
| 'BANK_ACCOUNT'
| 'MEDICAL_RECORD'
| 'OUTDOOR_LICENSE'
| 'WIRELESS_ROUTER'
| 'SOCIAL_SECURITY_NUMBER'
| 'CUSTOM'
/** Normalized vault shape matching the Connect API response. */
export interface NormalizedVault {
id: string
name: string
description: null
attributeVersion: number
contentVersion: number
items: number
type: string
createdAt: string | null
updatedAt: string | null
}
/** Normalized item overview shape matching the Connect API response. */
export interface NormalizedItemOverview {
id: string
title: string
vault: { id: string }
category: ConnectCategory
urls: Array<{ href: string; label: string | null; primary: boolean }>
favorite: boolean
tags: string[]
version: number
state: string | null
createdAt: string | null
updatedAt: string | null
lastEditedBy: null
}
/** Normalized field shape matching the Connect API response. */
interface NormalizedField {
id: string
label: string
type: ConnectFieldType
purpose: string
value: string | null
section: { id: string } | null
generate: boolean
recipe: null
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[]
}
/**
* SDK field type string values → Connect field type mapping.
* Uses string literals instead of enum imports to avoid loading the WASM module at build time.
*/
const SDK_TO_CONNECT_FIELD_TYPE: Record<string, ConnectFieldType> = {
Text: 'STRING',
Concealed: 'CONCEALED',
Email: 'EMAIL',
Url: 'URL',
Totp: 'OTP',
Phone: 'PHONE',
Date: 'DATE',
MonthYear: 'MONTH_YEAR',
Menu: 'MENU',
Address: 'ADDRESS',
Reference: 'REFERENCE',
SshKey: 'SSHKEY',
CreditCardNumber: 'CREDIT_CARD_NUMBER',
CreditCardType: 'CREDIT_CARD_TYPE',
}
/** SDK category string values → Connect category mapping. */
const SDK_TO_CONNECT_CATEGORY: Record<string, ConnectCategory> = {
Login: 'LOGIN',
Password: 'PASSWORD',
ApiCredentials: 'API_CREDENTIAL',
SecureNote: 'SECURE_NOTE',
Server: 'SERVER',
Database: 'DATABASE',
CreditCard: 'CREDIT_CARD',
Identity: 'IDENTITY',
SshKey: 'SSH_KEY',
Document: 'DOCUMENT',
SoftwareLicense: 'SOFTWARE_LICENSE',
Email: 'EMAIL_ACCOUNT',
Membership: 'MEMBERSHIP',
Passport: 'PASSPORT',
Rewards: 'REWARD_PROGRAM',
DriverLicense: 'DRIVER_LICENSE',
BankAccount: 'BANK_ACCOUNT',
MedicalRecord: 'MEDICAL_RECORD',
OutdoorLicense: 'OUTDOOR_LICENSE',
Router: 'WIRELESS_ROUTER',
SocialSecurityNumber: 'SOCIAL_SECURITY_NUMBER',
CryptoWallet: 'CUSTOM',
Person: 'CUSTOM',
Unsupported: 'CUSTOM',
}
/** Connect category → SDK category string mapping. */
const CONNECT_TO_SDK_CATEGORY: Record<string, `${ItemCategory}`> = {
LOGIN: 'Login',
PASSWORD: 'Password',
API_CREDENTIAL: 'ApiCredentials',
SECURE_NOTE: 'SecureNote',
SERVER: 'Server',
DATABASE: 'Database',
CREDIT_CARD: 'CreditCard',
IDENTITY: 'Identity',
SSH_KEY: 'SshKey',
DOCUMENT: 'Document',
SOFTWARE_LICENSE: 'SoftwareLicense',
EMAIL_ACCOUNT: 'Email',
MEMBERSHIP: 'Membership',
PASSPORT: 'Passport',
REWARD_PROGRAM: 'Rewards',
DRIVER_LICENSE: 'DriverLicense',
BANK_ACCOUNT: 'BankAccount',
MEDICAL_RECORD: 'MedicalRecord',
OUTDOOR_LICENSE: 'OutdoorLicense',
WIRELESS_ROUTER: 'Router',
SOCIAL_SECURITY_NUMBER: 'SocialSecurityNumber',
}
/** Connect field type → SDK field type string mapping. */
const CONNECT_TO_SDK_FIELD_TYPE: Record<string, `${ItemFieldType}`> = {
STRING: 'Text',
CONCEALED: 'Concealed',
EMAIL: 'Email',
URL: 'Url',
OTP: 'Totp',
TOTP: 'Totp',
PHONE: 'Phone',
DATE: 'Date',
MONTH_YEAR: 'MonthYear',
MENU: 'Menu',
ADDRESS: 'Address',
REFERENCE: 'Reference',
SSHKEY: 'SshKey',
CREDIT_CARD_NUMBER: 'CreditCardNumber',
CREDIT_CARD_TYPE: 'CreditCardType',
}
export type ConnectionMode = 'service_account' | 'connect'
export interface CredentialParams {
connectionMode?: ConnectionMode | null
serviceAccountToken?: string | null
serverUrl?: string | null
apiKey?: string | null
}
export interface ResolvedCredentials {
mode: ConnectionMode
serviceAccountToken?: string
serverUrl?: string
apiKey?: string
}
/** Determine which backend to use based on provided credentials. */
export function resolveCredentials(params: CredentialParams): ResolvedCredentials {
const mode = params.connectionMode ?? (params.serviceAccountToken ? 'service_account' : 'connect')
if (mode === 'service_account') {
if (!params.serviceAccountToken) {
throw new Error('Service Account token is required for Service Account mode')
}
return { mode, serviceAccountToken: params.serviceAccountToken }
}
if (!params.serverUrl || !params.apiKey) {
throw new Error('Server URL and Connect token are required for Connect Server mode')
}
return { mode, serverUrl: params.serverUrl, apiKey: params.apiKey }
}
/**
* Create a 1Password SDK client from a service account token.
* Uses dynamic import to avoid loading the WASM module at build time.
*/
export async function createOnePasswordClient(serviceAccountToken: string) {
const { createClient } = await import('@1password/sdk')
return createClient({
auth: serviceAccountToken,
integrationName: 'Sim Studio',
integrationVersion: '1.0.0',
})
}
const connectLogger = createLogger('OnePasswordConnect')
/**
* Enforces the SSRF policy for a resolved Connect server IP.
*
* On the hosted service, all private and reserved IPs are blocked — a tenant has
* no legitimate reason to point Connect at the platform's internal network. On
* self-hosted deployments only link-local (cloud metadata) is blocked, since the
* operator controls both the workflows and the network and Connect servers
* legitimately live on private (RFC1918) addresses.
*
* @throws Error if the IP is not permitted under the active policy.
*/
function assertConnectIpAllowed(ip: string, hostname: string): void {
if (isHosted) {
if (isPrivateOrReservedIP(ip)) {
connectLogger.warn('1Password Connect server URL resolves to a private or reserved IP', {
hostname,
resolvedIP: ip,
})
throw new Error('1Password server URL cannot point to a private or reserved IP address')
}
return
}
if (ipaddr.isValid(ip) && ipaddr.process(ip).range() === 'linkLocal') {
connectLogger.warn('1Password Connect server URL resolves to a link-local IP', {
hostname,
resolvedIP: ip,
})
throw new Error('1Password server URL cannot point to a link-local address')
}
}
/**
* Validates a Connect server URL against the SSRF policy and returns the resolved
* IP for DNS pinning to prevent TOCTOU rebinding. See {@link assertConnectIpAllowed}
* for the hosted vs. self-hosted policy.
* @throws Error if the URL is invalid, fails the IP policy, or DNS fails.
*/
export async function validateConnectServerUrl(serverUrl: string): Promise<string> {
let hostname: string
try {
hostname = new URL(serverUrl).hostname
} catch {
throw new Error('1Password server URL is not a valid URL')
}
const clean =
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
if (ipaddr.isValid(clean)) {
assertConnectIpAllowed(clean, clean)
return clean
}
let address: string
try {
;({ address } = await dns.lookup(clean, { verbatim: true }))
} catch (error) {
connectLogger.warn('DNS lookup failed for 1Password Connect server URL', {
hostname: clean,
error: toError(error).message,
})
throw new Error('1Password server URL hostname could not be resolved')
}
assertConnectIpAllowed(address, clean)
return address
}
/** Minimal response shape used by all connectRequest callers. */
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<any>
text: () => Promise<string>
arrayBuffer: () => Promise<ArrayBuffer>
}
/** Proxy a request to the 1Password Connect Server. */
export async function connectRequest(options: {
serverUrl: string
apiKey: string
path: string
method: string
body?: unknown
query?: string
}): Promise<ConnectResponse> {
const resolvedIP = await validateConnectServerUrl(options.serverUrl)
const base = options.serverUrl.replace(/\/$/, '')
const queryStr = options.query ? `?${options.query}` : ''
const url = `${base}${options.path}${queryStr}`
const headers: Record<string, string> = {
Authorization: `Bearer ${options.apiKey}`,
}
if (options.body) {
headers['Content-Type'] = 'application/json'
}
return secureFetchWithPinnedIP(url, resolvedIP, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
allowHttp: true,
})
}
/** Normalize an SDK VaultOverview to match Connect API vault shape. */
export function normalizeSdkVault(vault: VaultOverview): NormalizedVault {
return {
id: vault.id,
name: vault.title,
description: null,
attributeVersion: 0,
contentVersion: 0,
items: 0,
type: 'USER_CREATED',
createdAt:
vault.createdAt instanceof Date ? vault.createdAt.toISOString() : (vault.createdAt ?? null),
updatedAt:
vault.updatedAt instanceof Date ? vault.updatedAt.toISOString() : (vault.updatedAt ?? null),
}
}
/** Normalize an SDK ItemOverview to match Connect API item summary shape. */
export function normalizeSdkItemOverview(item: ItemOverview): NormalizedItemOverview {
return {
id: item.id,
title: item.title,
vault: { id: item.vaultId },
category: SDK_TO_CONNECT_CATEGORY[item.category] ?? 'CUSTOM',
urls: (item.websites ?? []).map((w: Website) => ({
href: w.url,
label: w.label ?? null,
primary: false,
})),
favorite: false,
tags: item.tags ?? [],
version: 0,
state: item.state === 'archived' ? 'ARCHIVED' : null,
createdAt:
item.createdAt instanceof Date ? item.createdAt.toISOString() : (item.createdAt ?? null),
updatedAt:
item.updatedAt instanceof Date ? item.updatedAt.toISOString() : (item.updatedAt ?? null),
lastEditedBy: null,
}
}
/** Normalize a full SDK Item to match Connect API FullItem shape. */
export function normalizeSdkItem(item: Item): NormalizedItem {
return {
id: item.id,
title: item.title,
vault: { id: item.vaultId },
category: SDK_TO_CONNECT_CATEGORY[item.category] ?? 'CUSTOM',
urls: (item.websites ?? []).map((w: Website) => ({
href: w.url,
label: w.label ?? null,
primary: false,
})),
favorite: false,
tags: item.tags ?? [],
version: item.version ?? 0,
state: null,
fields: (item.fields ?? []).map((field: ItemField) => ({
id: field.id,
label: field.title,
type: SDK_TO_CONNECT_FIELD_TYPE[field.fieldType] ?? 'STRING',
purpose: '',
value: field.value ?? null,
section: field.sectionId ? { id: field.sectionId } : null,
generate: false,
recipe: null,
entropy: null,
})),
sections: (item.sections ?? []).map((section: ItemSection) => ({
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:
item.updatedAt instanceof Date ? item.updatedAt.toISOString() : (item.updatedAt ?? null),
lastEditedBy: null,
}
}
/**
* 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<string, any>, 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<string, any>) => ({
// 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<string, any>) => ({
...(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<string, any>) => ({
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'
}
/** Convert a Connect-style field type string to the SDK field type string. */
export function toSdkFieldType(type: string): `${ItemFieldType}` {
return CONNECT_TO_SDK_FIELD_TYPE[type] ?? 'Text'
}