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
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:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user