d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
575 lines
18 KiB
TypeScript
575 lines
18 KiB
TypeScript
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'
|
|
}
|