diff --git a/alchemy-web/src/content/docs/providers/1password/index.md b/alchemy-web/src/content/docs/providers/1password/index.md new file mode 100644 index 000000000..d542059c9 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/1password/index.md @@ -0,0 +1,120 @@ +--- +title: 1Password +description: Securely manage secrets and credentials with 1Password +--- + +# 1Password + +The 1Password provider allows you to create and manage items in [1Password](https://1password.com) vaults, enabling secure secret management in your infrastructure-as-code deployments. + +## Installation + +::: code-group + +```sh [bun] +bun add @1password/sdk +``` + +```sh [npm] +npm install @1password/sdk +``` + +```sh [pnpm] +pnpm add @1password/sdk +``` + +```sh [yarn] +yarn add @1password/sdk +``` + +::: + +## Authentication + +The 1Password provider uses Service Account authentication. You'll need to: + +1. [Create a Service Account](https://my.1password.com/developer-tools/infrastructure-secrets/serviceaccount/) in your 1Password account +2. Give the service account appropriate permissions in your vaults +3. Set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable + +```bash +export OP_SERVICE_ACCOUNT_TOKEN= +``` + +## Resources + +- [Item](./item.md) - Create and manage items (logins, secure notes, API credentials, etc.) +- [ItemRef](./item.md#fetching-an-existing-item-itemref) - Fetch an existing item by vault ID and item ID + +## Example Usage + +```ts +import { Item, ItemRef } from "alchemy/1password"; + +// Create a secure note +const note = await Item("app-secrets", { + vault: "vault-id", + title: "Application Secrets", + category: "SecureNote", + notes: "Important configuration data", + tags: ["production", "api"], +}); + +// Fetch an existing item +const existingItem = await ItemRef({ + vaultId: "vault-id", + itemId: "item-id", +}); +console.log(existingItem.title); +console.log(existingItem.fields); + +// Create a login item +const login = await Item("service-login", { + vault: "vault-id", + title: "Service Account", + category: "Login", + fields: [ + { + id: "username", + title: "Username", + fieldType: "Text", + value: "service@example.com", + }, + { + id: "password", + title: "Password", + fieldType: "Concealed", + value: "secure-password", + }, + ], + websites: [ + { + url: "https://app.example.com", + label: "Application", + autofillBehavior: "AnywhereOnWebsite", + }, + ], +}); + +// Create an API credential +const apiKey = await Item("api-credentials", { + vault: "vault-id", + title: "Production API Key", + category: "ApiCredentials", + fields: [ + { + id: "api-key", + title: "API Key", + fieldType: "Concealed", + value: "sk_live_xxxxx", + sectionId: "credentials", + }, + ], + sections: [ + { + id: "credentials", + title: "Credentials", + }, + ], +}); +``` diff --git a/alchemy-web/src/content/docs/providers/1password/item.md b/alchemy-web/src/content/docs/providers/1password/item.md new file mode 100644 index 000000000..1ef4b566f --- /dev/null +++ b/alchemy-web/src/content/docs/providers/1password/item.md @@ -0,0 +1,202 @@ +--- +title: Item +description: Create and manage 1Password items including logins, secure notes, and API credentials +--- + +The Item resource lets you create and manage items in [1Password](https://1password.com) vaults, including logins, secure notes, API credentials, and more. + +## Minimal Example + +Create a basic secure note: + +```ts +import { Item } from "alchemy/1password"; + +const note = await Item("my-note", { + vault: "vault-id", + title: "My Secure Note", +}); +``` + +## Fetching an Existing Item (ItemRef) + +Use `ItemRef` to fetch an existing item by vault ID and item ID: + +```ts +import { ItemRef } from "alchemy/1password"; + +const item = await ItemRef({ + vaultId: "abc123", + itemId: "xyz789", +}); + +// Access the full item data +console.log(item.title); +console.log(item.fields); +console.log(item.notes); +``` + +:::note +`ItemRef` is a read-only reference that does not manage the item's lifecycle. It simply fetches and returns the existing item data. +::: + +## Login Item + +Create a login item with username, password, and website for autofill: + +```ts +import { Item } from "alchemy/1password"; + +const login = await Item("app-login", { + vault: "vault-id", + title: "My App Login", + category: "Login", + fields: [ + { + id: "username", + title: "Username", + fieldType: "Text", + value: "user@example.com", + }, + { + id: "password", + title: "Password", + fieldType: "Concealed", + value: "my-secret-password", + }, + ], + websites: [ + { + url: "https://app.example.com", + label: "Application", + autofillBehavior: "AnywhereOnWebsite", + }, + ], +}); +``` + +## API Credential + +Create an API credential with custom sections: + +```ts +import { Item } from "alchemy/1password"; + +const apiCred = await Item("api-key", { + vault: "vault-id", + title: "Production API Key", + category: "ApiCredentials", + fields: [ + { + id: "api-key", + title: "API Key", + fieldType: "Concealed", + value: "sk_live_xxxxx", + sectionId: "credentials", + }, + { + id: "api-url", + title: "API URL", + fieldType: "Url", + value: "https://api.example.com/v1", + sectionId: "credentials", + }, + ], + sections: [ + { + id: "credentials", + title: "Credentials", + }, + ], +}); +``` + +## Secure Note with Tags + +Create a secure note with tags for organization: + +```ts +import { Item } from "alchemy/1password"; + +const note = await Item("config-note", { + vault: "vault-id", + title: "Configuration Notes", + category: "SecureNote", + notes: "Important configuration details for production environment", + tags: ["production", "config", "sensitive"], +}); +``` + +## Prevent Deletion + +Create an item that won't be deleted when removed from Alchemy: + +```ts +import { Item } from "alchemy/1password"; + +const persistentItem = await Item("permanent-secret", { + vault: "vault-id", + title: "Permanent Secret", + category: "SecureNote", + notes: "This item will remain even after Alchemy cleanup", + delete: false, +}); +``` + +:::caution +When `delete: false` is set, the item will remain in 1Password after your Alchemy stack is destroyed. You'll need to manually delete it from 1Password if you no longer need it. +::: + +## Custom Service Account Token + +Use a specific service account token instead of the environment variable: + +```ts +import { Item } from "alchemy/1password"; + +const item = await Item("custom-auth-item", { + vault: "vault-id", + title: "Custom Auth Item", + serviceAccountToken: alchemy.secret(process.env.CUSTOM_OP_TOKEN), +}); +``` + +## Field Types + +The following field types are supported: + +| Field Type | Description | +|------------|-------------| +| `Text` | Plain text value | +| `Concealed` | Hidden/password value | +| `Url` | URL value | +| `Email` | Email address | +| `Phone` | Phone number | +| `Totp` | One-time password | +| `Date` | Date value | +| `MonthYear` | Month/Year value | +| `Address` | Address with components | +| `CreditCardType` | Credit card type | +| `CreditCardNumber` | Credit card number | +| `Reference` | Reference to another item | +| `SshKey` | SSH key | +| `Menu` | Menu selection | + +## Item Categories + +The following item categories are supported: + +| Category | Description | +|----------|-------------| +| `Login` | Website login credentials | +| `SecureNote` | Secure text notes | +| `ApiCredentials` | API keys and tokens | +| `Password` | Standalone password | +| `CreditCard` | Credit card information | +| `Identity` | Personal identity information | +| `Database` | Database credentials | +| `Server` | Server access credentials | +| `SshKey` | SSH key pairs | +| `Document` | Document storage | +| `BankAccount` | Bank account details | +| And more... | See 1Password documentation | diff --git a/alchemy/package.json b/alchemy/package.json index 36be39afe..59a173453 100644 --- a/alchemy/package.json +++ b/alchemy/package.json @@ -38,6 +38,10 @@ "bun": "./src/llms.ts", "import": "./lib/llms.js" }, + "./1password": { + "bun": "./src/1password/index.ts", + "import": "./lib/1password/index.js" + }, "./coinbase": { "bun": "./src/coinbase/index.ts", "import": "./lib/coinbase/index.js" @@ -207,6 +211,7 @@ "yaml": "^2.0.0" }, "peerDependencies": { + "@1password/sdk": "^0.1.6", "@astrojs/cloudflare": "^12.6.4", "@aws-sdk/client-dynamodb": "^3.0.0", "@coinbase/cdp-sdk": "^0.10.0", @@ -228,6 +233,9 @@ "wrangler": "catalog:" }, "peerDependenciesMeta": { + "@1password/sdk": { + "optional": true + }, "@astrojs/cloudflare": { "optional": true }, @@ -284,6 +292,7 @@ } }, "devDependencies": { + "@1password/sdk": "^0.1.6", "@astrojs/cloudflare": "^12.6.4", "@aws-sdk/client-dynamodb": "^3.0.0", "@aws-sdk/client-ec2": "^3.868.0", diff --git a/alchemy/src/1password/README.md b/alchemy/src/1password/README.md new file mode 100644 index 000000000..374500639 --- /dev/null +++ b/alchemy/src/1password/README.md @@ -0,0 +1,192 @@ +# 1Password Provider + +This provider enables management of 1Password items through the [1Password JavaScript SDK](https://github.com/1Password/onepassword-sdk-js). + +## Resources + +### Item + +The `Item` resource creates and manages items in 1Password vaults. + +**File**: `item.ts` + +**Features**: +- Create items in any vault +- Support for all item categories (Login, SecureNote, ApiCredentials, etc.) +- Custom fields with various field types +- Section organization +- Tags and notes +- Website autofill configuration +- Lifecycle management (create, update, delete) + +### ItemRef + +The `ItemRef` function fetches an existing item by vault ID and item ID. This is a read-only reference that does not manage the item's lifecycle. + +**File**: `item.ts` + +**Features**: +- Fetch existing items by vault ID and item ID +- Returns full item data including all fields, sections, tags, and notes +- Read-only access (no lifecycle management) + +## API Client + +**File**: `api.ts` + +The API client wraps the 1Password SDK's `createClient` function with sensible defaults and environment variable support. + +### Authentication + +The provider uses 1Password Service Account authentication: + +1. Create a [Service Account](https://my.1password.com/developer-tools/infrastructure-secrets/serviceaccount/) +2. Set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable +3. Or pass `serviceAccountToken` as a prop + +### Client Options + +| Option | Environment Variable | Description | +|--------|---------------------|-------------| +| `serviceAccountToken` | `OP_SERVICE_ACCOUNT_TOKEN` | Service Account Token for authentication | +| `integrationName` | - | Name to identify your integration (default: "Alchemy Integration") | +| `integrationVersion` | - | Version of your integration (default: "v1.0.0") | + +## Usage Examples + +### Basic Secure Note + +```ts +import { Item } from "alchemy/1password"; + +const note = await Item("my-note", { + vault: "vault-id", + title: "My Secure Note", + category: "SecureNote", + notes: "Secret content", +}); +``` + +### Fetch an Existing Item + +```ts +import { ItemRef } from "alchemy/1password"; + +const item = await ItemRef({ + vaultId: "vault-id", + itemId: "item-id", +}); + +console.log(item.title); +console.log(item.fields); +console.log(item.notes); +``` + +### Login with Fields + +```ts +import { Item } from "alchemy/1password"; + +const login = await Item("app-login", { + vault: "vault-id", + title: "Application Login", + category: "Login", + fields: [ + { + id: "username", + title: "Username", + fieldType: "Text", + value: "user@example.com", + }, + { + id: "password", + title: "Password", + fieldType: "Concealed", + value: "secret-password", + }, + ], + websites: [ + { + url: "https://app.example.com", + label: "Application", + autofillBehavior: "AnywhereOnWebsite", + }, + ], +}); +``` + +### API Credential with Sections + +```ts +import { Item } from "alchemy/1password"; + +const apiKey = await Item("api-key", { + vault: "vault-id", + title: "API Credentials", + category: "ApiCredentials", + fields: [ + { + id: "api-key", + title: "API Key", + fieldType: "Concealed", + value: "sk_live_xxx", + sectionId: "credentials", + }, + ], + sections: [ + { + id: "credentials", + title: "Credentials", + }, + ], +}); +``` + +## Supported Item Categories + +- Login +- SecureNote +- ApiCredentials +- Password +- CreditCard +- CryptoWallet +- Identity +- Document +- BankAccount +- Database +- DriverLicense +- Email +- MedicalRecord +- Membership +- OutdoorLicense +- Passport +- Rewards +- Router +- Server +- SshKey +- SocialSecurityNumber +- SoftwareLicense +- Person + +## Supported Field Types + +- Text - Plain text value +- Concealed - Hidden/password value +- Url - URL value +- Email - Email address +- Phone - Phone number +- Totp - One-time password +- Date - Date value +- MonthYear - Month/Year value +- Address - Address with components +- CreditCardType - Credit card type +- CreditCardNumber - Credit card number +- Reference - Reference to another item +- SshKey - SSH key +- Menu - Menu selection + +## Links + +- [1Password JavaScript SDK](https://github.com/1Password/onepassword-sdk-js) +- [1Password SDK Documentation](https://developer.1password.com/docs/sdks/) +- [Service Account Setup](https://developer.1password.com/docs/service-accounts/get-started/) diff --git a/alchemy/src/1password/api.ts b/alchemy/src/1password/api.ts new file mode 100644 index 000000000..8fa1338b7 --- /dev/null +++ b/alchemy/src/1password/api.ts @@ -0,0 +1,54 @@ +import type { Client as OnePasswordClient } from "@1password/sdk"; +import type { Secret } from "../secret.ts"; + +/** + * Options for 1Password API requests + */ +export interface OnePasswordApiOptions { + /** + * Service Account Token for authentication + * (overrides OP_SERVICE_ACCOUNT_TOKEN env var) + */ + serviceAccountToken?: Secret; + + /** + * Integration name to identify your application + * @default "Alchemy Integration" + */ + integrationName?: string; + + /** + * Integration version to identify your application version + * @default "v1.0.0" + */ + integrationVersion?: string; +} + +/** + * Creates a 1Password SDK client instance + * + * @param options API options + * @returns 1Password SDK client instance + */ +export async function createOnePasswordClient( + options: Partial = {}, +): Promise { + const sdk = await import("@1password/sdk"); + + const token = + options.serviceAccountToken?.unencrypted ?? + process.env.OP_SERVICE_ACCOUNT_TOKEN; + if (!token) { + throw new Error( + "1Password Service Account Token is required. Set OP_SERVICE_ACCOUNT_TOKEN environment variable or provide serviceAccountToken option.", + ); + } + + const client = await sdk.createClient({ + auth: token, + integrationName: options.integrationName ?? "Alchemy Integration", + integrationVersion: options.integrationVersion ?? "v1.0.0", + }); + + return client; +} diff --git a/alchemy/src/1password/index.ts b/alchemy/src/1password/index.ts new file mode 100644 index 000000000..1d923874c --- /dev/null +++ b/alchemy/src/1password/index.ts @@ -0,0 +1,2 @@ +export * from "./api.ts"; +export * from "./item.ts"; diff --git a/alchemy/src/1password/item.ts b/alchemy/src/1password/item.ts new file mode 100644 index 000000000..bf57dc9c1 --- /dev/null +++ b/alchemy/src/1password/item.ts @@ -0,0 +1,659 @@ +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { Secret } from "../secret.ts"; +import { + createOnePasswordClient, + type OnePasswordApiOptions, +} from "./api.ts"; + +/** + * Field types supported by 1Password items + */ +export type ItemFieldType = + | "Text" + | "Concealed" + | "CreditCardType" + | "CreditCardNumber" + | "Phone" + | "Url" + | "Totp" + | "Email" + | "Reference" + | "SshKey" + | "Menu" + | "MonthYear" + | "Address" + | "Date" + | "Unsupported"; + +/** + * Item categories supported by 1Password + */ +export type ItemCategory = + | "Login" + | "SecureNote" + | "CreditCard" + | "CryptoWallet" + | "Identity" + | "Password" + | "Document" + | "ApiCredentials" + | "BankAccount" + | "Database" + | "DriverLicense" + | "Email" + | "MedicalRecord" + | "Membership" + | "OutdoorLicense" + | "Passport" + | "Rewards" + | "Router" + | "Server" + | "SshKey" + | "SocialSecurityNumber" + | "SoftwareLicense" + | "Person" + | "Unsupported"; + +/** + * A field within a 1Password item + */ +export interface ItemField { + /** + * The field's unique ID + */ + id: string; + + /** + * The field's title/label + */ + title: string; + + /** + * The ID of the section containing the field (optional) + */ + sectionId?: string; + + /** + * The field's type + */ + fieldType: ItemFieldType; + + /** + * The field's value + */ + value: string; +} + +/** + * A section within a 1Password item + */ +export interface ItemSection { + /** + * The section's unique ID + */ + id: string; + + /** + * The section's title + */ + title: string; +} + +/** + * Website configuration for autofill + */ +export interface ItemWebsite { + /** + * The website URL + */ + url: string; + + /** + * The label for the website + */ + label: string; + + /** + * The auto-fill behavior + */ + autofillBehavior: "AnywhereOnWebsite" | "ExactDomain" | "Never"; +} + +/** + * Properties for creating or updating a 1Password Item + */ +export interface ItemProps extends OnePasswordApiOptions { + /** + * The vault where the item should be stored. + * Can be a vault ID or a Vault resource reference. + */ + vault: string; + + /** + * The item's title + */ + title: string; + + /** + * The item's category + * @default "SecureNote" + */ + category?: ItemCategory; + + /** + * The item's fields + */ + fields?: ItemField[]; + + /** + * The item's sections + */ + sections?: ItemSection[]; + + /** + * Notes associated with the item + */ + notes?: string; + + /** + * Tags to categorize the item + */ + tags?: string[]; + + /** + * Websites for autofill (Login and Password categories) + */ + websites?: ItemWebsite[]; + + /** + * Whether to delete the item when removed from Alchemy. + * @default true + */ + delete?: boolean; +} + +/** + * Output returned after 1Password Item creation/update + */ +export type Item = Omit & { + /** + * The item's unique ID assigned by 1Password + */ + id: string; + + /** + * The ID of the vault containing the item + */ + vaultId: string; + + /** + * The item's title + */ + title: string; + + /** + * The item's category + */ + category: ItemCategory; + + /** + * The item's fields + */ + fields: ItemField[]; + + /** + * The item's sections + */ + sections: ItemSection[]; + + /** + * Notes associated with the item + */ + notes: string; + + /** + * Tags associated with the item + */ + tags: string[]; + + /** + * Websites for autofill + */ + websites: ItemWebsite[]; + + /** + * The item's version number + */ + version: number; + + /** + * When the item was created + */ + createdAt: Date; + + /** + * When the item was last updated + */ + updatedAt: Date; +}; + +/** + * Type guard to check if a resource is a 1Password Item + */ +export function isItem(resource: unknown): resource is Item { + return ( + typeof resource === "object" && + resource !== null && + (resource as Record)[ResourceKind] === + "1password::Item" + ); +} + +/** + * Creates and manages a 1Password item. + * + * 1Password items store sensitive information like passwords, API keys, + * and other secrets in a secure vault. + * + * @example + * ## Create a Login Item + * + * Create a login item with username and password fields: + * + * ```ts + * import { Item } from "alchemy/1password"; + * + * const login = await Item("my-login", { + * vault: "vault-id", + * title: "My App Login", + * category: "Login", + * fields: [ + * { + * id: "username", + * title: "Username", + * fieldType: "Text", + * value: "myuser@example.com", + * }, + * { + * id: "password", + * title: "Password", + * fieldType: "Concealed", + * value: "my-secret-password", + * }, + * ], + * websites: [ + * { + * url: "https://example.com", + * label: "Website", + * autofillBehavior: "AnywhereOnWebsite", + * }, + * ], + * }); + * ``` + * + * @example + * ## Create a Secure Note + * + * Create a secure note to store sensitive information: + * + * ```ts + * import { Item } from "alchemy/1password"; + * + * const note = await Item("my-note", { + * vault: "vault-id", + * title: "API Configuration", + * category: "SecureNote", + * notes: "This is my secret configuration data", + * tags: ["api", "config"], + * }); + * ``` + * + * @example + * ## Create an API Credential + * + * Create an API credential item with custom fields: + * + * ```ts + * import { Item } from "alchemy/1password"; + * + * const apiCred = await Item("my-api-key", { + * vault: "vault-id", + * title: "Production API Key", + * category: "ApiCredentials", + * fields: [ + * { + * id: "api-key", + * title: "API Key", + * fieldType: "Concealed", + * value: "sk_live_xxxxx", + * }, + * { + * id: "api-url", + * title: "API URL", + * fieldType: "Url", + * value: "https://api.example.com/v1", + * }, + * ], + * sections: [ + * { + * id: "credentials", + * title: "Credentials", + * }, + * ], + * }); + * ``` + */ +export const Item = Resource( + "1password::Item", + async function ( + this: Context, + id: string, + props: ItemProps, + ): Promise { + const client = await createOnePasswordClient(props); + const sdk = await import("@1password/sdk"); + + const vaultId = props.vault; + const title = props.title; + const category = props.category ?? "SecureNote"; + + if (this.phase === "delete") { + if (props.delete !== false && this.output?.id) { + try { + await client.items.delete(this.output.vaultId, this.output.id); + } catch (error: unknown) { + // Ignore 404 errors - item may already be deleted + const errorMessage = + error instanceof Error ? error.message : String(error); + if (!errorMessage.includes("not found")) { + throw error; + } + } + } + return this.destroy(); + } + + // Valid categories for type safety + const validCategories = new Set([ + "Login", "SecureNote", "CreditCard", "CryptoWallet", "Identity", + "Password", "Document", "ApiCredentials", "BankAccount", "Database", + "DriverLicense", "Email", "MedicalRecord", "Membership", "OutdoorLicense", + "Passport", "Rewards", "Router", "Server", "SshKey", "SocialSecurityNumber", + "SoftwareLicense", "Person", "Unsupported" + ]); + + // Valid field types for type safety + const validFieldTypes = new Set([ + "Text", "Concealed", "CreditCardType", "CreditCardNumber", "Phone", + "Url", "Totp", "Email", "Reference", "SshKey", "Menu", "MonthYear", + "Address", "Date", "Unsupported" + ]); + + // Valid autofill behaviors + const validAutofillBehaviors = new Set([ + "AnywhereOnWebsite", "ExactDomain", "Never" + ]); + + // Map category to SDK enum with validation + const sdkCategory = validCategories.has(category) + ? (sdk.ItemCategory[category as keyof typeof sdk.ItemCategory] ?? sdk.ItemCategory.SecureNote) + : sdk.ItemCategory.SecureNote; + + // Map field types to SDK enum with validation + const mapFieldType = (fieldType: ItemFieldType) => { + if (validFieldTypes.has(fieldType)) { + return sdk.ItemFieldType[fieldType as keyof typeof sdk.ItemFieldType] ?? sdk.ItemFieldType.Text; + } + return sdk.ItemFieldType.Text; + }; + + // Map autofill behavior to SDK enum + const mapAutofillBehavior = (behavior: string) => { + switch (behavior) { + case "ExactDomain": + return sdk.AutofillBehavior.ExactDomain; + case "Never": + return sdk.AutofillBehavior.Never; + default: + return sdk.AutofillBehavior.AnywhereOnWebsite; + } + }; + + // Safe category mapping from SDK response + const mapCategoryFromSdk = (sdkCat: string): ItemCategory => { + if (validCategories.has(sdkCat)) { + return sdkCat as ItemCategory; + } + return "Unsupported"; + }; + + // Safe field type mapping from SDK response + const mapFieldTypeFromSdk = (sdkFieldType: string): ItemFieldType => { + if (validFieldTypes.has(sdkFieldType)) { + return sdkFieldType as ItemFieldType; + } + return "Unsupported"; + }; + + // Safe autofill behavior mapping from SDK response + const mapAutofillBehaviorFromSdk = (sdkBehavior: string): ItemWebsite["autofillBehavior"] => { + if (validAutofillBehaviors.has(sdkBehavior)) { + return sdkBehavior as ItemWebsite["autofillBehavior"]; + } + return "AnywhereOnWebsite"; + }; + + let result: Awaited>; + + if (this.phase === "update" && this.output?.id) { + // Get the existing item to update + const existingItem = await client.items.get( + this.output.vaultId, + this.output.id, + ); + + // Update the item fields + const updatedItem = { + ...existingItem, + title, + category: sdkCategory, + fields: + props.fields?.map((f) => ({ + id: f.id, + title: f.title, + sectionId: f.sectionId, + fieldType: mapFieldType(f.fieldType), + value: f.value, + })) ?? existingItem.fields, + sections: + props.sections?.map((s) => ({ + id: s.id, + title: s.title, + })) ?? existingItem.sections, + notes: props.notes ?? existingItem.notes, + tags: props.tags ?? existingItem.tags, + websites: + props.websites?.map((w) => ({ + url: w.url, + label: w.label, + autofillBehavior: mapAutofillBehavior(w.autofillBehavior), + })) ?? existingItem.websites, + }; + + result = await client.items.put(updatedItem); + } else { + // Create new item + result = await client.items.create({ + vaultId, + title, + category: sdkCategory, + fields: props.fields?.map((f) => ({ + id: f.id, + title: f.title, + sectionId: f.sectionId, + fieldType: mapFieldType(f.fieldType), + value: f.value, + })), + sections: props.sections?.map((s) => ({ + id: s.id, + title: s.title, + })), + notes: props.notes, + tags: props.tags, + websites: props.websites?.map((w) => ({ + url: w.url, + label: w.label, + autofillBehavior: mapAutofillBehavior(w.autofillBehavior), + })), + }); + } + + // Map the result back to our output format + return { + id: result.id, + vault: vaultId, + vaultId: result.vaultId, + title: result.title, + category: mapCategoryFromSdk(result.category as string), + fields: result.fields.map((f: { id: string; title: string; sectionId?: string; fieldType: string; value: string }) => ({ + id: f.id, + title: f.title, + sectionId: f.sectionId, + fieldType: mapFieldTypeFromSdk(f.fieldType), + value: f.value, + })), + sections: result.sections.map((s: { id: string; title: string }) => ({ + id: s.id, + title: s.title, + })), + notes: result.notes, + tags: result.tags, + websites: result.websites.map((w: { url: string; label: string; autofillBehavior: string }) => ({ + url: w.url, + label: w.label, + autofillBehavior: mapAutofillBehaviorFromSdk(w.autofillBehavior), + })), + version: result.version, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + }; + }, +); + +/** + * Properties for referencing an existing 1Password Item + */ +export interface ItemRefProps extends OnePasswordApiOptions { + /** + * The ID of the vault containing the item + */ + vaultId: string; + + /** + * The ID of the item to fetch + */ + itemId: string; +} + +/** + * Fetches an existing 1Password item by vault ID and item ID. + * This is a read-only reference that does not manage the item's lifecycle. + * + * @example + * ```ts + * import { ItemRef } from "alchemy/1password"; + * + * const item = await ItemRef({ + * vaultId: "abc123", + * itemId: "xyz789", + * }); + * + * console.log(item.title); + * console.log(item.fields); + * ``` + * + * @param props The properties to identify the item + * @returns The full item data including all fields + */ +export async function ItemRef( + props: ItemRefProps, +): Promise { + const client = await createOnePasswordClient(props); + + // Valid categories for type safety + const validCategories = new Set([ + "Login", "SecureNote", "CreditCard", "CryptoWallet", "Identity", + "Password", "Document", "ApiCredentials", "BankAccount", "Database", + "DriverLicense", "Email", "MedicalRecord", "Membership", "OutdoorLicense", + "Passport", "Rewards", "Router", "Server", "SshKey", "SocialSecurityNumber", + "SoftwareLicense", "Person", "Unsupported" + ]); + + // Valid field types for type safety + const validFieldTypes = new Set([ + "Text", "Concealed", "CreditCardType", "CreditCardNumber", "Phone", + "Url", "Totp", "Email", "Reference", "SshKey", "Menu", "MonthYear", + "Address", "Date", "Unsupported" + ]); + + // Valid autofill behaviors + const validAutofillBehaviors = new Set([ + "AnywhereOnWebsite", "ExactDomain", "Never" + ]); + + // Safe category mapping from SDK response + const mapCategoryFromSdk = (sdkCat: string): ItemCategory => { + if (validCategories.has(sdkCat)) { + return sdkCat as ItemCategory; + } + return "Unsupported"; + }; + + // Safe field type mapping from SDK response + const mapFieldTypeFromSdk = (sdkFieldType: string): ItemFieldType => { + if (validFieldTypes.has(sdkFieldType)) { + return sdkFieldType as ItemFieldType; + } + return "Unsupported"; + }; + + // Safe autofill behavior mapping from SDK response + const mapAutofillBehaviorFromSdk = (sdkBehavior: string): ItemWebsite["autofillBehavior"] => { + if (validAutofillBehaviors.has(sdkBehavior)) { + return sdkBehavior as ItemWebsite["autofillBehavior"]; + } + return "AnywhereOnWebsite"; + }; + + const result = await client.items.get(props.vaultId, props.itemId); + + return { + id: result.id, + vault: props.vaultId, + vaultId: result.vaultId, + title: result.title, + category: mapCategoryFromSdk(result.category as string), + fields: result.fields.map((f: { id: string; title: string; sectionId?: string; fieldType: string; value: string }) => ({ + id: f.id, + title: f.title, + sectionId: f.sectionId, + fieldType: mapFieldTypeFromSdk(f.fieldType), + value: f.value, + })), + sections: result.sections.map((s: { id: string; title: string }) => ({ + id: s.id, + title: s.title, + })), + notes: result.notes, + tags: result.tags, + websites: result.websites.map((w: { url: string; label: string; autofillBehavior: string }) => ({ + url: w.url, + label: w.label, + autofillBehavior: mapAutofillBehaviorFromSdk(w.autofillBehavior), + })), + version: result.version, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + }; +} diff --git a/alchemy/test/1password/item.test.ts b/alchemy/test/1password/item.test.ts new file mode 100644 index 000000000..a6ce7e866 --- /dev/null +++ b/alchemy/test/1password/item.test.ts @@ -0,0 +1,308 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { destroy } from "../../src/destroy.ts"; +import { createOnePasswordClient } from "../../src/1password/api.ts"; +import { Item, ItemRef, isItem } from "../../src/1password/item.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +// must import this or else alchemy.test won't exist +import "../../src/test/vitest.ts"; + +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +// Get vault ID from environment +const vaultId = process.env.OP_VAULT_ID; + +describe("1Password Item Resource", () => { + // Use BRANCH_PREFIX for deterministic, non-colliding resource names + const testId = `${BRANCH_PREFIX}-test-1password-item`; + + test.skipIf(!process.env.OP_SERVICE_ACCOUNT_TOKEN || !vaultId)( + "create, update, and delete 1password item", + async (scope) => { + let item: Item | undefined; + // Create client once and reuse + const client = await createOnePasswordClient(); + try { + // Create a test 1Password item - Secure Note + const itemTitle = `Test Item ${testId}`; + item = await Item(testId, { + vault: vaultId!, + title: itemTitle, + category: "SecureNote", + notes: "This is a test note", + tags: ["test", "alchemy"], + }); + + expect(item.id).toBeTruthy(); + expect(item.title).toEqual(itemTitle); + expect(item.category).toEqual("SecureNote"); + expect(item.notes).toEqual("This is a test note"); + expect(item.tags).toContain("test"); + expect(item.tags).toContain("alchemy"); + expect(item.vaultId).toEqual(vaultId); + expect(item.createdAt).toBeInstanceOf(Date); + expect(item.updatedAt).toBeInstanceOf(Date); + + // Test type guard + expect(isItem(item)).toBe(true); + expect(isItem({})).toBe(false); + expect(isItem(null)).toBe(false); + + // Verify item was created by querying the API directly + const fetchedItem = await client.items.get(item.vaultId, item.id); + expect(fetchedItem.title).toEqual(itemTitle); + + // Update the item + const updatedTitle = `${itemTitle} Updated`; + item = await Item(testId, { + vault: vaultId!, + title: updatedTitle, + category: "SecureNote", + notes: "This is an updated test note", + tags: ["test", "alchemy", "updated"], + }); + + expect(item.id).toBeTruthy(); + expect(item.title).toEqual(updatedTitle); + expect(item.notes).toEqual("This is an updated test note"); + expect(item.tags).toContain("updated"); + + // Verify item was updated + const updatedFetchedItem = await client.items.get( + item.vaultId, + item.id, + ); + expect(updatedFetchedItem.title).toEqual(updatedTitle); + } finally { + // Always clean up, even if test assertions fail + await destroy(scope); + + // Verify item was deleted + if (item?.id) { + try { + await client.items.get(item.vaultId, item.id); + // If we get here, the item wasn't deleted + expect.fail("Item should have been deleted"); + } catch (error: unknown) { + // Expected - item should not exist + const errorMessage = + error instanceof Error ? error.message : String(error); + expect(errorMessage).toMatch(/not found|does not exist/i); + } + } + } + }, + ); + + test.skipIf(!process.env.OP_SERVICE_ACCOUNT_TOKEN || !vaultId)( + "create login item with fields", + async (scope) => { + let item: Item | undefined; + try { + const itemTitle = `Test Login ${testId}`; + item = await Item(`${testId}-login`, { + vault: vaultId!, + title: itemTitle, + category: "Login", + fields: [ + { + id: "username", + title: "Username", + fieldType: "Text", + value: "testuser@example.com", + }, + { + id: "password", + title: "Password", + fieldType: "Concealed", + value: "test-password-123", + }, + ], + websites: [ + { + url: "https://example.com", + label: "Website", + autofillBehavior: "AnywhereOnWebsite", + }, + ], + }); + + expect(item.id).toBeTruthy(); + expect(item.title).toEqual(itemTitle); + expect(item.category).toEqual("Login"); + expect(item.fields).toHaveLength(2); + + const usernameField = item.fields.find((f) => f.id === "username"); + expect(usernameField?.value).toEqual("testuser@example.com"); + + const passwordField = item.fields.find((f) => f.id === "password"); + expect(passwordField?.fieldType).toEqual("Concealed"); + + expect(item.websites).toHaveLength(1); + expect(item.websites[0].url).toEqual("https://example.com"); + } finally { + await destroy(scope); + } + }, + ); + + test.skipIf(!process.env.OP_SERVICE_ACCOUNT_TOKEN || !vaultId)( + "does not delete item when delete is false", + async (scope) => { + let item: Item | undefined; + // Create client once and reuse + const client = await createOnePasswordClient(); + try { + const itemTitle = `Test No Delete ${testId}`; + item = await Item(`${testId}-no-delete`, { + vault: vaultId!, + title: itemTitle, + category: "SecureNote", + delete: false, + }); + + expect(item.id).toBeTruthy(); + } finally { + await destroy(scope); + + if (item?.id) { + // Verify item still exists + const fetchedItem = await client.items.get(item.vaultId, item.id); + expect(fetchedItem.id).toEqual(item.id); + + // Manually delete the item for cleanup + await client.items.delete(item.vaultId, item.id); + } + } + }, + ); +}); + +describe("1Password ItemRef Function", () => { + // Use BRANCH_PREFIX for deterministic, non-colliding resource names + const testId = `${BRANCH_PREFIX}-test-1password-itemref`; + + test.skipIf(!process.env.OP_SERVICE_ACCOUNT_TOKEN || !vaultId)( + "fetch existing item by vault ID and item ID", + async () => { + // Create client to create an item directly via API + const client = await createOnePasswordClient(); + const sdk = await import("@1password/sdk"); + + // Create a test item via the API directly + const itemTitle = `Test ItemRef ${testId}`; + const createdItem = await client.items.create({ + vaultId: vaultId!, + title: itemTitle, + category: sdk.ItemCategory.SecureNote, + notes: "This is a test note for ItemRef", + tags: ["test", "itemref"], + }); + + try { + // Use ItemRef to fetch the existing item + const fetchedItem = await ItemRef({ + vaultId: vaultId!, + itemId: createdItem.id, + }); + + // Verify the fetched item matches the created item + expect(fetchedItem.id).toEqual(createdItem.id); + expect(fetchedItem.vaultId).toEqual(vaultId); + expect(fetchedItem.title).toEqual(itemTitle); + expect(fetchedItem.category).toEqual("SecureNote"); + expect(fetchedItem.notes).toEqual("This is a test note for ItemRef"); + expect(fetchedItem.tags).toContain("test"); + expect(fetchedItem.tags).toContain("itemref"); + expect(fetchedItem.version).toBeTruthy(); + expect(fetchedItem.createdAt).toBeInstanceOf(Date); + expect(fetchedItem.updatedAt).toBeInstanceOf(Date); + + // Verify arrays are returned correctly + expect(fetchedItem.fields).toBeInstanceOf(Array); + expect(fetchedItem.sections).toBeInstanceOf(Array); + expect(fetchedItem.websites).toBeInstanceOf(Array); + } finally { + // Clean up - delete the item we created + await client.items.delete(vaultId!, createdItem.id); + } + }, + ); + + test.skipIf(!process.env.OP_SERVICE_ACCOUNT_TOKEN || !vaultId)( + "fetch login item with fields and websites", + async () => { + // Create client to create an item directly via API + const client = await createOnePasswordClient(); + const sdk = await import("@1password/sdk"); + + // Create a login item via the API directly + const itemTitle = `Test Login ItemRef ${testId}`; + const createdItem = await client.items.create({ + vaultId: vaultId!, + title: itemTitle, + category: sdk.ItemCategory.Login, + fields: [ + { + id: "username", + title: "Username", + fieldType: sdk.ItemFieldType.Text, + value: "testuser@example.com", + }, + { + id: "password", + title: "Password", + fieldType: sdk.ItemFieldType.Concealed, + value: "test-password-456", + }, + ], + websites: [ + { + url: "https://test.example.com", + label: "Test Site", + autofillBehavior: sdk.AutofillBehavior.AnywhereOnWebsite, + }, + ], + }); + + try { + // Use ItemRef to fetch the existing item + const fetchedItem = await ItemRef({ + vaultId: vaultId!, + itemId: createdItem.id, + }); + + // Verify the fetched item matches the created item + expect(fetchedItem.id).toEqual(createdItem.id); + expect(fetchedItem.title).toEqual(itemTitle); + expect(fetchedItem.category).toEqual("Login"); + + // Verify fields were fetched correctly + expect(fetchedItem.fields.length).toBeGreaterThanOrEqual(2); + const usernameField = fetchedItem.fields.find( + (f) => f.id === "username", + ); + expect(usernameField?.value).toEqual("testuser@example.com"); + expect(usernameField?.fieldType).toEqual("Text"); + + const passwordField = fetchedItem.fields.find( + (f) => f.id === "password", + ); + expect(passwordField?.fieldType).toEqual("Concealed"); + + // Verify websites were fetched correctly + expect(fetchedItem.websites.length).toBeGreaterThanOrEqual(1); + const website = fetchedItem.websites.find( + (w) => w.url === "https://test.example.com", + ); + expect(website?.label).toEqual("Test Site"); + expect(website?.autofillBehavior).toEqual("AnywhereOnWebsite"); + } finally { + // Clean up - delete the item we created + await client.items.delete(vaultId!, createdItem.id); + } + }, + ); +});