diff --git a/examples/bun-toolkit/.env.example b/examples/bun-toolkit/.env.example new file mode 100644 index 0000000..fa127fd --- /dev/null +++ b/examples/bun-toolkit/.env.example @@ -0,0 +1,24 @@ +# LLM Configuration +# Required for credential issuance from documents + +# LLM Provider: "claude", "gemini", "openai", or "azure-openai" (default: claude) +# LLM_PROVIDER=claude + +# LLM API Key +# For Claude: Get your API key from https://console.anthropic.com +# For Gemini: Get your API key from https://aistudio.google.com/apikey +# For OpenAI: Get your API key from https://platform.openai.com/api-keys +# For Azure OpenAI: Get your API key from Azure Portal (Keys and Endpoint section) +LLM_API_KEY=your-api-key-here + +# Azure OpenAI Configuration (only needed if LLM_PROVIDER=azure-openai) +# AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/ +# AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name + +# Verifiable Credential Issuer Configuration +# Required for signing credentials +VC_CLI_ISSUER_DID=did:web:your-domain.com +VC_CLI_PRIVATE_KEY_PATH=~/vc-cli/did-web-your-domain.com/private-key.json + +# Optional: Output directory for generated files +# VC_CLI_OUTPUT_DIR=~/Downloads/vc-cli/credentials diff --git a/examples/bun-toolkit/CLI.md b/examples/bun-toolkit/CLI.md index 67e8e0d..e8418fa 100644 --- a/examples/bun-toolkit/CLI.md +++ b/examples/bun-toolkit/CLI.md @@ -1,11 +1,12 @@ -# VC-CLI: DID Document Generator +# VC-CLI: Verifiable Credentials Toolkit -A command-line tool for generating `did:web` identifiers with cryptographic keys for verifiable credentials. +A command-line tool for managing `did:web` identifiers and issuing verifiable credentials from documents. ## Prerequisites - [Bun](https://bun.sh) installed on your system - This repository cloned locally +- For credential issuance: LLM API key (Claude, Gemini, or OpenAI) ## Quick Start @@ -15,7 +16,7 @@ Navigate to the toolkit directory: cd examples/bun-toolkit ``` -Generate a DID document for your domain: +### Generate a DID ```bash bun vc-cli did generate contoso.com @@ -27,6 +28,21 @@ This will: - Create a W3C-compliant DID document - Save files to `~/Downloads/vc-cli/did-web-contoso.com/` +### Issue a Credential from a Document + +```bash +# Set up environment variables first (see Configuration section) +bun vc-cli credential issue ~/Downloads/document.pdf +``` + +This will: + +- Extract content from the document using AI +- Generate structured credential data +- Create a JSON Schema +- Issue and sign a verifiable credential +- Save three files: `.md`, `-schema.yaml`, `.vc.jwt.txt` + ## Usage ### Basic Command @@ -130,6 +146,312 @@ The verify command automatically handles path conversion: | ----------------- | ----------------------------- | | `--show-document` | Display the full DID document | +### Managing Verifiable Credentials + +#### Issuing Credentials from Documents + +Issue verifiable credentials by extracting data from documents using AI: + +```bash +bun vc-cli credential issue [options] +``` + +**Examples:** + +Issue a credential from a PDF: + +```bash +bun vc-cli credential issue ~/Downloads/metallurgical-report.pdf +``` + +Issue with custom output location: + +```bash +bun vc-cli credential issue invoice.png --output-path ~/credentials +``` + +Override issuer and private key: + +```bash +bun vc-cli credential issue document.pdf \ + --issuer did:web:example.com \ + --private-key ~/keys/private-key.json +``` + +Set validity period: + +```bash +bun vc-cli credential issue certificate.pdf \ + --valid-from 2025-01-01T00:00:00Z \ + --valid-until 2026-01-01T00:00:00Z +``` + +**Supported Document Formats:** + +- **Images**: PNG, JPG, JPEG, GIF, WebP +- **Documents**: PDF +- **Text**: JSON, CSV, YAML, TXT, Markdown + +**What the credential issue command does:** + +1. šŸ“ **Extracts content** from the document using LLM Vision API (for images/PDFs) or direct parsing (for text files) +2. šŸ” **Structures the data** using AI to identify key fields and relationships +3. šŸ“ **Generates a JSON Schema** that describes the credential structure +4. šŸ” **Issues and signs** a W3C Verifiable Credential using your private key +5. šŸ’¾ **Saves three files**: + - `{filename}-{timestamp}.md` - Extracted markdown representation + - `{filename}-{timestamp}-schema.yaml` - JSON Schema definition + - `{filename}-{timestamp}.vc.jwt.txt` - Signed verifiable credential (JWT) + +**Credential Issue Options:** + +| Option | Description | Default | +| --------------------------- | ------------------------------------------- | ------------------- | +| `--issuer ` | Override issuer DID | From env | +| `--private-key ` | Override private key path | From env | +| `--output-path ` | Output directory | `~/Downloads/vc-cli/` | +| `--valid-from ` | Credential validity start (ISO 8601 format) | Current timestamp | +| `--valid-until ` | Credential validity end (ISO 8601 format) | No expiration | + +#### Signing Credentials + +Sign an existing credential JSON file: + +```bash +bun vc-cli credential sign [options] +``` + +**Examples:** + +Sign a credential file: + +```bash +bun vc-cli credential sign ~/Documents/my-credential.json +``` + +Override issuer and private key: + +```bash +bun vc-cli credential sign credential.json \ + --issuer did:web:example.com \ + --private-key ~/keys/private-key.json +``` + +Specify output location: + +```bash +bun vc-cli credential sign credential.json --output-path ~/signed-credentials +``` + +**What the credential sign command does:** + +1. šŸ“– **Reads the credential JSON file** and validates it has required fields (`@context`, `type`, `credentialSubject`) +2. šŸ” **Signs the credential** using your private key +3. šŸ’¾ **Saves the signed JWT** to `{filename}-signed-{timestamp}.vc.jwt.txt` + +**Credential Sign Options:** + +| Option | Description | Default | +| ---------------------- | --------------------- | ----------------------- | +| `--issuer ` | Override issuer DID | From env or credential | +| `--private-key ` | Override private key | From env | +| `--output-path ` | Output directory | `~/Downloads/vc-cli/credentials` | + +#### Verifying Credentials + +Verify a signed credential JWT: + +```bash +bun vc-cli credential verify [options] +``` + +**Examples:** + +Verify a credential: + +```bash +bun vc-cli credential verify ~/Downloads/credential.vc.jwt.txt +``` + +Verify with schema validation: + +```bash +bun vc-cli credential verify credential.vc.jwt.txt \ + --schema ~/schemas/my-schema.yaml +``` + +**What the credential verify command does:** + +1. āœ… **Verifies the credential** by decoding the JWT +2. 🌐 **Resolves the issuer DID** by fetching the DID document from the web (did:web resolution) +3. šŸ” **Validates the signature** using the issuer's public key +4. šŸ“‹ **Validates against schema** (if `--schema` provided) to ensure the credential matches the expected structure +5. šŸ“Š **Displays verification results** including issuer, type, validity dates, and signature status + +**Verification Steps Performed:** + +- → Verifying credential... +- → Resolving issuer DID... +- → Validating signature... +- → Validating schema... (if schema provided) +- āœ“ Credential verified successfully + +**Verification Details Displayed:** + +- āœ“ **Issuer**: The DID of the credential issuer +- āœ“ **Type**: The credential type(s) +- āœ“ **Valid From**: When the credential becomes valid +- āœ“ **Valid Until**: When the credential expires (if set) +- āœ“ **Signature**: Valid/Invalid status +- āœ“ **Schema**: Valid/Invalid status (if schema validation was performed) + +**Credential Verify Options:** + +| Option | Description | Default | +| ----------------- | --------------------------------------- | ------- | +| `--schema ` | Optional YAML schema file to validate against | None | + +### Managing Verifiable Presentations + +#### Creating Presentations + +Create a verifiable presentation from one or more signed credentials: + +```bash +bun vc-cli presentation create [path2 ...] [options] +``` + +**Note:** Each `` can be either: +- A **credential file** (e.g., `cert.vc.jwt.txt`) +- A **directory** containing credential files (automatically scans for `*.vc.jwt.txt` files) + +**Examples:** + +Create a presentation from all credentials in a directory: + +```bash +bun vc-cli presentation create ~/Downloads/vc-cli/credentials +``` + +Create a presentation with a single credential file: + +```bash +bun vc-cli presentation create ~/Downloads/vc-cli/credentials/cert.vc.jwt.txt +``` + +Create a presentation with multiple credential files: + +```bash +bun vc-cli presentation create \ + ~/Downloads/vc-cli/credentials/cert1.vc.jwt.txt \ + ~/Downloads/vc-cli/credentials/cert2.vc.jwt.txt +``` + +Mix directories and files: + +```bash +bun vc-cli presentation create \ + ~/Downloads/vc-cli/credentials \ + ~/special-cert.vc.jwt.txt +``` + +Override holder DID: + +```bash +bun vc-cli presentation create ~/Downloads/vc-cli/credentials \ + --holder did:web:holder.example.com +``` + +Set custom expiration (in seconds): + +```bash +bun vc-cli presentation create ~/Downloads/vc-cli/credentials \ + --expires-in 1800 +``` + +**What the presentation create command does:** + +1. šŸ“ **Loads credential JWT files** and validates they exist +2. šŸ“¦ **Creates enveloped credentials** in W3C VC v2 format +3. šŸŽ­ **Builds a verifiable presentation** with the holder's DID +4. šŸ” **Signs the presentation** using the holder's authentication key +5. šŸ’¾ **Saves the signed presentation** to `presentation-{timestamp}.vp.jwt.txt` + +**Presentation Create Options:** + +| Option | Description | Default | +| ---------------------- | ------------------------------------------- | ----------------------------- | +| `--holder ` | Override holder DID | From env (VC_CLI_ISSUER_DID) | +| `--private-key ` | Override private key path | From env | +| `--output-path ` | Output directory | `~/Downloads/vc-cli/presentations` | +| `--expires-in ` | Presentation expiration time in seconds | 3600 (1 hour) | + +**Important Notes:** + +- Presentations use the **authentication key** (not the assertion key used for issuing credentials) +- The holder DID defaults to your configured issuer DID from `.env` +- Presentations are typically short-lived for security (default 1 hour) +- Multiple credentials can be bundled in a single presentation +- Each credential is wrapped as an `EnvelopedVerifiableCredential` + +## Configuration + +Before issuing credentials, create a `.env` file in the toolkit directory: + +```bash +# LLM Configuration +# Required for credential issuance from documents + +# LLM Provider: "claude", "gemini", "openai", or "azure-openai" (default: claude) +# LLM_PROVIDER=claude + +# LLM API Key +# For Claude: Get your API key from https://console.anthropic.com +# For Gemini: Get your API key from https://aistudio.google.com/apikey +# For OpenAI: Get your API key from https://platform.openai.com/api-keys +# For Azure OpenAI: Get your API key from Azure Portal (Keys and Endpoint section) +LLM_API_KEY=your-api-key-here + +# Azure OpenAI Configuration (only needed if LLM_PROVIDER=azure-openai) +# AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/ +# AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name + +# Verifiable Credential Issuer Configuration +# Required for signing credentials +VC_CLI_ISSUER_DID=did:web:your-domain.com +VC_CLI_PRIVATE_KEY_PATH=~/Downloads/vc-cli/did-web-your-domain.com/private-key.json + +# Optional: Output directory for generated files +# VC_CLI_OUTPUT_DIR=~/Downloads/vc-cli/credentials +``` + +### Supported LLM Providers + +The CLI supports multiple LLM providers for document extraction: + +- **Claude (Anthropic)** - Default provider using Claude Sonnet 4.5 + - Get API key from: https://console.anthropic.com + - Set `LLM_PROVIDER=claude` (or omit, as it's the default) + - Model: `claude-sonnet-4-20250514` + +- **Google Gemini** - Google's Generative AI + - Get API key from: https://aistudio.google.com/apikey + - Set `LLM_PROVIDER=gemini` + - Model: `gemini-2.0-flash-exp` + +- **OpenAI** - OpenAI's GPT models + - Get API key from: https://platform.openai.com/api-keys + - Set `LLM_PROVIDER=openai` + - Model: `gpt-4o` + +- **Azure OpenAI** - Microsoft's Azure OpenAI Service + - Get API key from: Azure Portal (Keys and Endpoint section of your Azure OpenAI resource) + - Set `LLM_PROVIDER=azure-openai` + - Also requires: `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_DEPLOYMENT_NAME` + - Model: Uses your configured deployment (e.g., `gpt-4o`, `gpt-4`) + +See `.env.example` for a complete template. + ## Options | Option | Description | Default | @@ -247,7 +569,9 @@ Check the output messages to see where files were saved. Only ES256 and ES384 are currently supported. EdDSA support may be added in the future. -## Example Workflow +## Example Workflows + +### Workflow 1: Set Up Your DID ```bash # 1. Generate your DID @@ -262,7 +586,29 @@ scp ~/Downloads/vc-cli/did-web-contoso.com/did.json \ # 4. Verify it's accessible curl https://contoso.com/.well-known/did.json -# 5. Your DID is now resolvable: did:web:contoso.com +# 5. Verify using the CLI +bun vc-cli did verify did:web:contoso.com + +# 6. Your DID is now resolvable: did:web:contoso.com +``` + +### Workflow 2: Issue a Credential from a Document + +```bash +# 1. Set up your .env file with API keys and DID configuration +cp .env.example .env +# Edit .env with your API key and DID + +# 2. Issue a credential from a document +bun vc-cli credential issue ~/Documents/certificate.pdf + +# 3. Three files are created in ~/Downloads/vc-cli/: +# - certificate-2025-10-15T19-43-15.md (extracted content) +# - certificate-2025-10-15T19-43-15-schema.yaml (JSON Schema) +# - certificate-2025-10-15T19-43-15.vc.jwt.txt (signed credential) + +# 4. The .vc.jwt.txt file contains the verifiable credential +# that can be shared and verified by anyone ``` ## Help Command diff --git a/examples/bun-toolkit/bun.lock b/examples/bun-toolkit/bun.lock index 3a01a0d..5f85bfe 100644 --- a/examples/bun-toolkit/bun.lock +++ b/examples/bun-toolkit/bun.lock @@ -4,10 +4,14 @@ "": { "name": "entity_identifiers", "dependencies": { + "@anthropic-ai/sdk": "^0.66.0", + "@google/generative-ai": "^0.24.1", "ajv": "^8.17.1", "ajv-formats": "^2.1.1", "jose": "^6.1.0", "js-yaml": "^4.1.0", + "openai": "^6.5.0", + "pdf-to-png-converter": "^3.10.0", }, "devDependencies": { "@types/bun": "latest", @@ -19,6 +23,34 @@ }, }, "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.66.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GBSSby0P4BW/sOdvsTXaHJDPnGEL5tuB4TtsU4SXG7+dVULQ9MkKgNznCALDCgSV5yhrtQlctvEdMqePVIXTiw=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.80", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.80", "@napi-rs/canvas-darwin-arm64": "0.1.80", "@napi-rs/canvas-darwin-x64": "0.1.80", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", "@napi-rs/canvas-linux-arm64-musl": "0.1.80", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", "@napi-rs/canvas-linux-x64-gnu": "0.1.80", "@napi-rs/canvas-linux-x64-musl": "0.1.80", "@napi-rs/canvas-win32-x64-msvc": "0.1.80" } }, "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.80", "", { "os": "android", "cpu": "arm64" }, "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.80", "", { "os": "darwin", "cpu": "arm64" }, "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.80", "", { "os": "darwin", "cpu": "x64" }, "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.80", "", { "os": "linux", "cpu": "arm" }, "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.80", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.80", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.80", "", { "os": "linux", "cpu": "none" }, "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.80", "", { "os": "linux", "cpu": "x64" }, "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.80", "", { "os": "linux", "cpu": "x64" }, "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.80", "", { "os": "win32", "cpu": "x64" }, "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg=="], + "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -45,10 +77,20 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "openai": ["openai@6.5.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-bNqJ15Ijbs41KuJ2iYz/mGAruFHzQQt7zXo4EvjNLoB64aJdgn1jlMeDTsXjEg+idVYafg57QB/5Rd16oqvZ6A=="], + + "pdf-to-png-converter": ["pdf-to-png-converter@3.10.0", "", { "dependencies": { "@napi-rs/canvas": "~0.1.80", "pdfjs-dist": "~5.4.149" } }, "sha512-kRRoacnX88gvpRPQWtPd9igsSXnyItXnPy8hIKBDmhOauCImHjP7+3DClKsVZc2PR4/u9p2cJmXJ4Ry1g6U2Xw=="], + + "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], diff --git a/examples/bun-toolkit/example-documents/bill-of-lading.pdf b/examples/bun-toolkit/example-documents/bill-of-lading.pdf new file mode 100644 index 0000000..1011739 Binary files /dev/null and b/examples/bun-toolkit/example-documents/bill-of-lading.pdf differ diff --git a/examples/bun-toolkit/example-documents/commercial-invoice.pdf b/examples/bun-toolkit/example-documents/commercial-invoice.pdf new file mode 100644 index 0000000..741f8e4 Binary files /dev/null and b/examples/bun-toolkit/example-documents/commercial-invoice.pdf differ diff --git a/examples/bun-toolkit/example-documents/hardware-bom.pdf b/examples/bun-toolkit/example-documents/hardware-bom.pdf new file mode 100644 index 0000000..068018a Binary files /dev/null and b/examples/bun-toolkit/example-documents/hardware-bom.pdf differ diff --git a/examples/bun-toolkit/example-documents/purchase-order.pdf b/examples/bun-toolkit/example-documents/purchase-order.pdf new file mode 100644 index 0000000..10b6f6f Binary files /dev/null and b/examples/bun-toolkit/example-documents/purchase-order.pdf differ diff --git a/examples/bun-toolkit/package-lock.json b/examples/bun-toolkit/package-lock.json new file mode 100644 index 0000000..f3f037c --- /dev/null +++ b/examples/bun-toolkit/package-lock.json @@ -0,0 +1,341 @@ +{ + "name": "entity_identifiers", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "entity_identifiers", + "dependencies": { + "@anthropic-ai/sdk": "^0.66.0", + "@google/generative-ai": "^0.24.1", + "ajv": "^8.17.1", + "ajv-formats": "^2.1.1", + "jose": "^6.1.0", + "js-yaml": "^4.1.0", + "moment": "^2.30.1", + "openai": "^6.5.0", + "pdf-to-png-converter": "^3.10.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/js-yaml": "^4.0.9" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.66.0", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/bun": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.1.tgz", + "integrity": "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.1" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.12.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/bun-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.1.tgz", + "integrity": "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/jose": { + "version": "6.1.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/openai": { + "version": "6.5.0", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/pdf-to-png-converter": { + "version": "3.10.0", + "license": "MIT", + "dependencies": { + "@napi-rs/canvas": "~0.1.80", + "pdfjs-dist": "~5.4.149" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.12.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.18.3", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/examples/bun-toolkit/package.json b/examples/bun-toolkit/package.json index 81d41f9..78dcce0 100644 --- a/examples/bun-toolkit/package.json +++ b/examples/bun-toolkit/package.json @@ -13,9 +13,14 @@ "typescript": "^5" }, "dependencies": { + "@anthropic-ai/sdk": "^0.66.0", + "@google/generative-ai": "^0.24.1", "ajv": "^8.17.1", "ajv-formats": "^2.1.1", "jose": "^6.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "moment": "^2.30.1", + "openai": "^6.5.0", + "pdf-to-png-converter": "^3.10.0" } } diff --git a/examples/bun-toolkit/src/cli/cli-utils.ts b/examples/bun-toolkit/src/cli/cli-utils.ts index 4026a7e..ec56dda 100644 --- a/examples/bun-toolkit/src/cli/cli-utils.ts +++ b/examples/bun-toolkit/src/cli/cli-utils.ts @@ -1,10 +1,6 @@ -import { generatePrivateKey, exportPublicKey } from "../key"; -import * as path from "path"; -import * as fs from "fs"; -import * as os from "os"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; -import * as yaml from "js-yaml"; +/** + * Shared CLI utilities, types, and helper functions + */ export interface GenerateOptions { lei?: string; @@ -16,28 +12,33 @@ export interface VerifyOptions { "show-document"?: boolean; } -export type Algorithm = "ES256" | "ES384"; +export interface IssueCredentialOptions { + issuer?: string; + "private-key"?: string; + "output-path"?: string; + "valid-from"?: string; + "valid-until"?: string; +} + +export interface SignCredentialOptions { + issuer?: string; + "private-key"?: string; + "output-path"?: string; +} -interface DidDocument { - "@context": string[]; - id: string; - verificationMethod: Array<{ - id: string; - type: string; - controller: string; - publicKeyJwk: { - kty: string; - crv: string; - alg: string; - x: string; - y: string; - }; - }>; - assertionMethod: string[]; - authentication: string[]; - alsoKnownAs?: string[]; +export interface VerifyCredentialOptions { + schema?: string; } +export interface CreatePresentationOptions { + holder?: string; + "private-key"?: string; + "output-path"?: string; + "expires-in"?: string; +} + +export type Algorithm = "ES256" | "ES384"; + export function printVersion() { console.log("Verifiable Supply Chain Toolkit v0.1.0"); } @@ -54,6 +55,14 @@ Commands: Examples: contoso.com contoso.com/organizations/contoso did verify [options] Verify a did:web is properly hosted + credential issue [options] Issue a verifiable credential from a document or file + Supports PDF, images, JSON, CSV, YAML + credential sign [options] Sign a credential JSON file + credential verify [options] Verify a signed credential file + presentation create [...] [opts] Create a verifiable presentation from credentials + Supports multiple paths + Path can be a credential file or directory + Credential files must end in .vc.jwt.txt version Show version information help Show this help message @@ -66,257 +75,26 @@ Options for 'did generate': Options for 'did verify': --show-document Display the full DID document (optional) -`); -} - -export async function generateDid(domainInput: string, options: GenerateOptions) { - console.log(`\nšŸ“‹ Command: did generate`); - console.log(`šŸ“ Input: ${domainInput}`); - - // Parse domain and path - convert slashes to colons for DID format - // contoso.com/organizations/123 -> domain: contoso.com, path: organizations:123 - const parts = domainInput.split("/"); - const domain = parts[0]; - const pathParts = parts.slice(1).filter(p => p.length > 0); - const didPath = pathParts.length > 0 ? `:${pathParts.join(":")}` : ""; - - if (pathParts.length > 0) { - console.log(`šŸ“ Domain: ${domain}`); - console.log(`šŸ“ Path: /${pathParts.join("/")}`); - } - - // Parse options - const algorithm = (options.algorithm || "ES256").toUpperCase() as Algorithm; - const lei = options.lei; - const customKeyPath = options["output-path"]; - - // Validate algorithm - if (algorithm !== "ES256" && algorithm !== "ES384") { - console.error(`\nāŒ Error: Unsupported algorithm "${algorithm}"`); - console.error(` Available algorithms: ES256, ES384`); - process.exit(1); - } - - console.log(`\nšŸ”‘ Generating cryptographic keys (${algorithm})...`); - - // Generate assertion key (for issuing credentials) - const assertionPrivateKey = await generatePrivateKey(algorithm); - const assertionPublicKey = await exportPublicKey(assertionPrivateKey); - - console.log(` āœ“ Assertion key generated`); - console.log(` Kid: ${assertionPrivateKey.kid}`); - - // Generate authentication key (for presenting credentials) - const authenticationPrivateKey = await generatePrivateKey(algorithm); - const authenticationPublicKey = await exportPublicKey(authenticationPrivateKey); - - console.log(` āœ“ Authentication key generated`); - console.log(` Kid: ${authenticationPrivateKey.kid}`); - - console.log(`\nšŸ“„ Building DID document...`); - - // Build the DID document (W3C DID standard format) - const didId = `did:web:${domain}${didPath}`; - - const didDocument: DidDocument = { - "@context": [ - "https://www.w3.org/ns/cid/v1", - ], - id: didId, - verificationMethod: [ - { - id: `${didId}#${assertionPublicKey.kid}`, - type: "JsonWebKey", - controller: didId, - publicKeyJwk: { - kty: assertionPublicKey.kty, - crv: assertionPublicKey.crv, - alg: assertionPublicKey.alg, - x: assertionPublicKey.x, - y: assertionPublicKey.y - } - }, - { - id: `${didId}#${authenticationPublicKey.kid}`, - type: "JsonWebKey", - controller: didId, - publicKeyJwk: { - kty: authenticationPublicKey.kty, - crv: authenticationPublicKey.crv, - alg: authenticationPublicKey.alg, - x: authenticationPublicKey.x, - y: authenticationPublicKey.y - } - } - ], - assertionMethod: [ - `${didId}#${assertionPublicKey.kid}` - ], - authentication: [ - `${didId}#${authenticationPublicKey.kid}` - ] - }; - - // Add LEI if provided - if (lei) { - didDocument.alsoKnownAs = [`urn:ietf:spice:glue:lei:${lei}`]; - } - - console.log(` āœ“ DID document created`); - - console.log(`\nšŸ’¾ Saving files...`); - // Determine storage path - use Downloads if it exists, otherwise home directory - let baseDir: string; - if (customKeyPath) { - baseDir = customKeyPath; - } else { - const homeDir = os.homedir(); - const downloadsDir = path.join(homeDir, "Downloads"); - - // Check if Downloads directory exists (common on macOS, Windows, Linux desktops) - if (fs.existsSync(downloadsDir)) { - baseDir = path.join(downloadsDir, "vc-cli"); - } else { - // Fallback to home directory if Downloads doesn't exist (e.g., Linux servers) - baseDir = path.join(homeDir, "vc-cli"); - } - } - - // Create safe directory name by replacing special characters - const safeDidName = `did-web-${domain}${didPath}`.replace(/:/g, "-"); - const keyPath = path.join(baseDir, safeDidName); - - // Create directory - if (!fs.existsSync(keyPath)) { - fs.mkdirSync(keyPath, { recursive: true }); - } - - // Save DID document - const didDocPath = path.join(keyPath, "did.json"); - await Bun.write(didDocPath, JSON.stringify(didDocument, null, 2)); - console.log(` āœ“ DID document saved: ${didDocPath}`); - - // Save private keys - const privateKeyPath = path.join(keyPath, "private-key.json"); - const privateKeys = { - assertion: assertionPrivateKey, - authentication: authenticationPrivateKey - }; - await Bun.write(privateKeyPath, JSON.stringify(privateKeys, null, 2)); - console.log(` āœ“ Private keys saved: ${privateKeyPath}`); - - // Save public keys - const publicKeyPath = path.join(keyPath, "public-key.json"); - const publicKeys = { - assertion: assertionPublicKey, - authentication: authenticationPublicKey - }; - await Bun.write(publicKeyPath, JSON.stringify(publicKeys, null, 2)); - console.log(` āœ“ Public keys saved: ${publicKeyPath}`); - - console.log(`\nšŸ“‹ DID Document:`); - console.log(JSON.stringify(didDocument, null, 2)); - - console.log(`\nāœ… Files saved successfully!\n`); - console.log(`Keys stored at:`); - console.log(` ${keyPath}\n`); -} - -export async function verifyDid(did: string, options: VerifyOptions) { - console.log(`\nšŸ“‹ Command: did verify`); - console.log(`šŸ” DID: ${did}\n`); - - const showDocument = options["show-document"] || false; - - // Parse the DID - if (!did.startsWith("did:web:")) { - console.error("āŒ Error: Invalid DID format. Expected format: did:web:[:]"); - process.exit(1); - } - - // Parse did:web according to the spec - // did:web:contoso.com -> https://contoso.com/.well-known/did.json - // did:web:contoso.com:user:alice -> https://contoso.com/user/alice/did.json - const didParts = did.replace("did:web:", "").split(":"); - const domain = didParts[0]; - const pathParts = didParts.slice(1); - - let url: string; - if (pathParts.length > 0) { - // Has path components - construct URL with path - const path = pathParts.join("/"); - url = `https://${domain}/${path}/did.json`; - } else { - // No path components - use .well-known - url = `https://${domain}/.well-known/did.json`; - } - - console.log(`šŸ“ Domain: ${domain}`); - if (pathParts.length > 0) { - console.log(`šŸ“ Path: /${pathParts.join("/")}`); - } - console.log(`🌐 Fetching: ${url}\n`); - - try { - // Fetch the DID document - const response = await fetch(url); - - if (!response.ok) { - console.error(`āŒ Failed to fetch DID document: ${response.status} ${response.statusText}`); - process.exit(1); - } - - console.log(`āœ… HTTP ${response.status} - DID document found`); - - // Parse JSON - let didDocument: any; - try { - didDocument = await response.json(); - console.log(`āœ… Valid JSON structure`); - } catch (jsonError) { - console.error(`\nāŒ Invalid JSON: ${jsonError}`); - process.exit(1); - } - - // Validate against DID document schema - const schemaPath = path.resolve(__dirname, "../../schemas/did-document.yaml"); - const schemaContent = fs.readFileSync(schemaPath, "utf8"); - const schema = yaml.load(schemaContent) as any; - - const ajv = new Ajv({ allErrors: true }); - addFormats(ajv); - const validate = ajv.compile(schema); - - const valid = validate(didDocument); - - if (!valid) { - console.error(`\nāŒ Invalid DID document structure:`); - validate.errors?.forEach(err => { - console.error(` - ${err.instancePath || "root"}: ${err.message}`); - }); - process.exit(1); - } - - console.log(`āœ… Valid DID document structure`); - console.log(` - ID: ${didDocument.id}`); - if (didDocument.verificationMethod) { - console.log(` - Verification methods: ${didDocument.verificationMethod.length}`); - } - if (didDocument.alsoKnownAs) { - console.log(` - Also known as: ${didDocument.alsoKnownAs.length} identifier(s)`); - } - - console.log(`\nāœ… DID verification successful!`); - - if (showDocument) { - console.log(`\nDID Document:`); - console.log(JSON.stringify(didDocument, null, 2)); - } - console.log(); - - } catch (error) { - console.error(`\nāŒ Error verifying DID: ${error}`); - process.exit(1); - } +Options for 'credential issue': + --issuer Override issuer DID (default: from env) + --private-key Override private key path (default: from env) + --output-path Output directory (default: ~/Downloads/vc-cli/ or ~/vc-cli/) + --valid-from Credential validity start (default: current timestamp) + --valid-until Credential validity end (default: no expiration) + +Options for 'credential sign': + --issuer Override issuer DID (default: from env) + --private-key Override private key path (default: from env) + --output-path Output directory (default: ~/Downloads/vc-cli/ or ~/vc-cli/) + +Options for 'credential verify': + --schema Optional schema file to validate against + +Options for 'presentation create': + --holder Override holder DID (default: from env) + --private-key Override private key path (default: from env) + --output-path Output directory (default: ~/Downloads/vc-cli/presentations) + --expires-in Presentation expiration in seconds (default: 3600) +`); } diff --git a/examples/bun-toolkit/src/cli/cli.ts b/examples/bun-toolkit/src/cli/cli.ts index 80c3aee..cc159bd 100755 --- a/examples/bun-toolkit/src/cli/cli.ts +++ b/examples/bun-toolkit/src/cli/cli.ts @@ -1,7 +1,13 @@ #!/usr/bin/env bun import { parseArgs } from "util"; -import { printVersion, printHelp, generateDid, verifyDid } from "./cli-utils"; +import { printVersion, printHelp } from "./cli-utils"; +import { generateDid } from "./commands/did-generate"; +import { verifyDid } from "./commands/did-verify"; +import { issueCredential } from "./commands/credential-issue"; +import { signCredential } from "./commands/credential-sign"; +import { verifyCredential } from "./commands/credential-verify"; +import { createPresentation } from "./commands/presentation-create"; try { const { positionals, values } = parseArgs({ @@ -11,7 +17,14 @@ try { lei: { type: "string" }, algorithm: { type: "string" }, "output-path": { type: "string" }, - "show-document": { type: "boolean" } + "show-document": { type: "boolean" }, + issuer: { type: "string" }, + "private-key": { type: "string" }, + "valid-from": { type: "string" }, + "valid-until": { type: "string" }, + schema: { type: "string" }, + holder: { type: "string" }, + "expires-in": { type: "string" } } }); @@ -51,6 +64,53 @@ try { } break; + case "credential": + if (subcommand === "issue") { + if (!argument) { + console.error("Error: document file argument is required"); + console.log("Usage: vc-cli credential issue "); + process.exit(1); + } + await issueCredential(argument, values); + } else if (subcommand === "sign") { + if (!argument) { + console.error("Error: credential file argument is required"); + console.log("Usage: vc-cli credential sign "); + process.exit(1); + } + await signCredential(argument, values); + } else if (subcommand === "verify") { + if (!argument) { + console.error("Error: credential file argument is required"); + console.log("Usage: vc-cli credential verify "); + process.exit(1); + } + await verifyCredential(argument, values); + } else { + console.error(`Unknown credential subcommand: ${subcommand}`); + printHelp(); + process.exit(1); + } + break; + + case "presentation": + if (subcommand === "create") { + // Get all paths (files or directories) from positionals starting at index 2 + const credentialPaths = positionals.slice(2); + if (credentialPaths.length === 0) { + console.error("Error: at least one path (file or directory) is required"); + console.log("Usage: vc-cli presentation create [path2 ...]"); + console.log(" Path can be a credential file or directory containing credentials"); + process.exit(1); + } + await createPresentation(credentialPaths, values); + } else { + console.error(`Unknown presentation subcommand: ${subcommand}`); + printHelp(); + process.exit(1); + } + break; + default: console.error(`Unknown command: ${command}`); printHelp(); diff --git a/examples/bun-toolkit/src/cli/commands/credential-issue.ts b/examples/bun-toolkit/src/cli/commands/credential-issue.ts new file mode 100644 index 0000000..bfc932c --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/credential-issue.ts @@ -0,0 +1,146 @@ +import { expandPath } from "../../utils/path"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { extractMarkdown, extractStructure, generateExampleData } from "../../llm/llm-client"; +import { generateYamlSchema } from "../../schema/generator"; +import { issueCredential as issueVC } from "../../credential/issuer"; +import { loadFromEnv, validateCredentialConfig } from "../../config/env"; +import type { IssueCredentialOptions } from "../cli-utils"; + +export async function issueCredential(sourceDocument: string, options: IssueCredentialOptions) { + console.log(`\nšŸ“‹ Command: credential issue`); + console.log(`šŸ“„ Source document: ${sourceDocument}\n`); + + // Step 1: Load and validate environment configuration + console.log(`šŸ”§ Loading configuration...`); + const config = loadFromEnv(); + + // Override with command-line options if provided + if (options.issuer) { + config.issuerDid = options.issuer; + } + if (options["private-key"]) { + config.privateKeyPath = expandPath(options["private-key"]); + } + + const validation = validateCredentialConfig(config); + if (!validation.valid) { + console.error(`\nāŒ Configuration error - missing required environment variables:`); + validation.missing.forEach(key => { + console.error(` - ${key}`); + }); + console.error(`\nPlease set these in your .env file or use command-line options.`); + console.error(`See .env.example for reference.\n`); + process.exit(1); + } + + console.log(` āœ“ Configuration loaded`); + console.log(` LLM Provider: ${config.llmProvider}`); + console.log(` Issuer: ${config.issuerDid}`); + + // Expand source document path + const documentPath = expandPath(sourceDocument); + if (!fs.existsSync(documentPath)) { + console.error(`\nāŒ Document file not found: ${documentPath}\n`); + process.exit(1); + } + + // Step 2: Extract markdown from document + console.log(`\nšŸ“ Extracting content from document...`); + const llmConfig = { + provider: config.llmProvider, + apiKey: config.llmApiKey!, + azureEndpoint: config.azureOpenAIEndpoint, + azureDeploymentName: config.azureOpenAIDeploymentName, + }; + const markdown = await extractMarkdown(documentPath, llmConfig); + console.log(` āœ“ Content extracted (${markdown.length} characters)`); + + // Step 3: Extract structured data from markdown + console.log(`\nšŸ” Extracting structured data...`); + const structure = await extractStructure(markdown, llmConfig); + console.log(` āœ“ Structured data extracted`); + console.log(` Document type: ${structure.documentType}`); + console.log(` Credential types: ${structure.credentialTypes.join(", ")}`); + + // Step 3.5: Generate example data + console.log(`\nšŸŽ­ Generating example data...`); + const exampleData = await generateExampleData( + structure.data, + structure.documentType, + llmConfig + ); + console.log(` āœ“ Example data generated`); + + // Step 4: Generate YAML schema with examples + console.log(`\nšŸ“ Generating schema...`); + const schemaResult = generateYamlSchema({ + credentialTypes: structure.credentialTypes, + data: structure.data, + metadata: structure.schemaMetadata, + exampleData: exampleData, + }); + console.log(` āœ“ Schema generated`); + + // Step 5: Issue and sign credential + console.log(`\nšŸ” Issuing verifiable credential...`); + const credentialJWT = await issueVC( + config.issuerDid!, + config.privateKeyPath!, + structure.credentialTypes, + structure.data, + { + validFrom: options["valid-from"], + validUntil: options["valid-until"], + } + ); + console.log(` āœ“ Credential issued and signed`); + + // Step 6: Save all three deliverables + console.log(`\nšŸ’¾ Saving outputs...`); + + // Determine storage path - append /credentials to base directory + let outputDir: string; + if (options["output-path"]) { + outputDir = expandPath(options["output-path"]); + } else { + const homeDir = os.homedir(); + const downloadsDir = path.join(homeDir, "Downloads"); + + // Check if Downloads directory exists (common on macOS, Windows, Linux desktops) + if (fs.existsSync(downloadsDir)) { + outputDir = path.join(downloadsDir, "vc-cli", "credentials"); + } else { + // Fallback to home directory if Downloads doesn't exist (e.g., Linux servers) + outputDir = path.join(homeDir, "vc-cli", "credentials"); + } + } + + // Create output directory if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Generate base filename from source document + const baseName = path.basename(documentPath, path.extname(documentPath)); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); + const filePrefix = `${baseName}-${timestamp}`; + + // Save markdown + const markdownPath = path.join(outputDir, `${filePrefix}.md`); + await Bun.write(markdownPath, markdown); + console.log(` āœ“ Markdown: ${markdownPath}`); + + // Save schema + const schemaPath = path.join(outputDir, `${filePrefix}-schema.yaml`); + await Bun.write(schemaPath, schemaResult.yaml); + console.log(` āœ“ Schema: ${schemaPath}`); + + // Save credential (as raw JWT text) + const credentialPath = path.join(outputDir, `${filePrefix}.vc.jwt.txt`); + await Bun.write(credentialPath, credentialJWT); + console.log(` āœ“ Credential: ${credentialPath}`); + + console.log(`\nāœ… Credential issuance complete!\n`); +} diff --git a/examples/bun-toolkit/src/cli/commands/credential-sign.ts b/examples/bun-toolkit/src/cli/commands/credential-sign.ts new file mode 100644 index 0000000..df1aab1 --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/credential-sign.ts @@ -0,0 +1,135 @@ +import { expandPath } from "../../utils/path"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { issueCredential as issueVC } from "../../credential/issuer"; +import { loadFromEnv, validateCredentialConfig } from "../../config/env"; +import type { SignCredentialOptions } from "../cli-utils"; + +export async function signCredential(credentialFile: string, options: SignCredentialOptions) { + console.log(`\nšŸ“‹ Command: credential sign`); + console.log(`šŸ“„ Credential file: ${credentialFile}\n`); + + // Step 1: Load and validate environment configuration + console.log(`šŸ”§ Loading configuration...`); + const config = loadFromEnv(); + + // Override with command-line options if provided + if (options.issuer) { + config.issuerDid = options.issuer; + } + if (options["private-key"]) { + config.privateKeyPath = expandPath(options["private-key"]); + } + + const validation = validateCredentialConfig(config); + if (!validation.valid) { + console.error(`\nāŒ Configuration error - missing required environment variables:`); + validation.missing.forEach(key => { + console.error(` - ${key}`); + }); + console.error(`\nPlease set these in your .env file or use command-line options.`); + console.error(`See .env.example for reference.\n`); + process.exit(1); + } + + console.log(` āœ“ Configuration loaded`); + console.log(` Issuer: ${config.issuerDid}`); + + // Step 2: Read and parse the credential JSON file + const credentialPath = expandPath(credentialFile); + if (!fs.existsSync(credentialPath)) { + console.error(`\nāŒ Credential file not found: ${credentialPath}\n`); + process.exit(1); + } + + console.log(`\nšŸ“– Reading credential file...`); + let credential: any; + try { + const fileContent = fs.readFileSync(credentialPath, "utf-8"); + credential = JSON.parse(fileContent); + console.log(` āœ“ File parsed successfully`); + } catch (error) { + console.error(`\nāŒ Invalid JSON file: ${error}\n`); + process.exit(1); + } + + // Step 3: Validate credential structure + console.log(`\nšŸ” Validating credential structure...`); + + // Check for required fields + if (!credential["@context"]) { + console.error(`\nāŒ Missing required field: @context\n`); + process.exit(1); + } + if (!credential.type) { + console.error(`\nāŒ Missing required field: type\n`); + process.exit(1); + } + if (!credential.credentialSubject) { + console.error(`\nāŒ Missing required field: credentialSubject\n`); + process.exit(1); + } + + console.log(` āœ“ Credential structure valid`); + + // Extract credential types + const credentialTypes = Array.isArray(credential.type) + ? credential.type + : [credential.type]; + console.log(` Credential types: ${credentialTypes.join(", ")}`); + + // Step 4: Sign the credential + console.log(`\nšŸ” Signing credential...`); + + // Use the provided issuer or keep the existing one + const issuerDid = credential.issuer || config.issuerDid!; + const credentialSubject = credential.credentialSubject; + + const credentialJWT = await issueVC( + issuerDid, + config.privateKeyPath!, + credentialTypes, + credentialSubject, + { + validFrom: credential.validFrom, + validUntil: credential.validUntil, + } + ); + console.log(` āœ“ Credential signed`); + + // Step 5: Save the signed credential + console.log(`\nšŸ’¾ Saving output...`); + + // Determine storage path + let outputDir: string; + if (options["output-path"]) { + outputDir = expandPath(options["output-path"]); + } else { + const homeDir = os.homedir(); + const downloadsDir = path.join(homeDir, "Downloads"); + + if (fs.existsSync(downloadsDir)) { + outputDir = path.join(downloadsDir, "vc-cli", "credentials"); + } else { + outputDir = path.join(homeDir, "vc-cli", "credentials"); + } + } + + // Create output directory if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Generate output filename + const baseName = path.basename(credentialPath, path.extname(credentialPath)); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); + const outputFileName = `${baseName}-signed-${timestamp}.vc.jwt.txt`; + const outputPath = path.join(outputDir, outputFileName); + + // Save signed credential + await Bun.write(outputPath, credentialJWT); + console.log(` āœ“ Signed credential: ${outputPath}`); + + console.log(`\nāœ… Credential signing complete!\n`); +} diff --git a/examples/bun-toolkit/src/cli/commands/credential-verify.ts b/examples/bun-toolkit/src/cli/commands/credential-verify.ts new file mode 100644 index 0000000..b897d7c --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/credential-verify.ts @@ -0,0 +1,141 @@ +import { expandPath } from "../../utils/path"; +import * as fs from "fs"; +import * as yaml from "js-yaml"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import moment from "moment"; +import { credentialVerifierFromResolver } from "../../credential/credentialVerifierFromResolver"; +import type { VerifyCredentialOptions } from "../cli-utils"; + +export async function verifyCredential(credentialFile: string, options: VerifyCredentialOptions) { + console.log(`\nšŸ“‹ Command: credential verify`); + console.log(`šŸ“„ Credential file: ${credentialFile}\n`); + + // Step 1: Read the credential JWT file + const credentialPath = expandPath(credentialFile); + if (!fs.existsSync(credentialPath)) { + console.error(`\nāŒ Credential file not found: ${credentialPath}\n`); + process.exit(1); + } + + // Step 1: Read credential file + console.log(`šŸ“ Reading credential file...`); + let jwsString: string; + try { + jwsString = fs.readFileSync(credentialPath, "utf-8").trim(); + console.log(` āœ“ File loaded`); + } catch (error) { + console.error(`\nāŒ Failed to read credential file: ${error}\n`); + process.exit(1); + } + + // Step 2: Verify the credential (this includes DID resolution and signature validation) + console.log(`\nšŸ” Verifying credential...`); + let credential: any; + let signatureValid = false; + + try { + const verifier = await credentialVerifierFromResolver(); + console.log(` āœ“ Issuer DID resolved`); + + credential = await verifier.verify(jwsString); + signatureValid = true; + console.log(` āœ“ Signature validated`); + } catch (error) { + console.error(`\nāŒ Verification failed: ${error}`); + if (error instanceof Error && error.stack) { + console.error(`\nStack trace:\n${error.stack}`); + } + console.error(); + process.exit(1); + } + + // Step 3: Validate against schema if provided + let schemaValid: boolean | null = null; + if (options.schema) { + console.log(`\nšŸ“‹ Validating schema...`); + const schemaPath = expandPath(options.schema); + + if (!fs.existsSync(schemaPath)) { + console.error(`\nāŒ Schema file not found: ${schemaPath}\n`); + process.exit(1); + } + + try { + const schemaContent = fs.readFileSync(schemaPath, "utf8"); + const schema = yaml.load(schemaContent) as any; + + // Use strict: false to allow non-standard keywords like "example" which are common in schemas + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + const validate = ajv.compile(schema); + + const valid = validate(credential); + + if (!valid) { + console.error(`\nāŒ Schema validation failed:`); + validate.errors?.forEach(err => { + console.error(` - ${err.instancePath || "root"}: ${err.message}`); + }); + schemaValid = false; + } else { + console.log(` āœ“ Schema valid`); + schemaValid = true; + } + } catch (error) { + console.error(`\nāŒ Schema validation error: ${error}\n`); + process.exit(1); + } + } + + // Step 4: Display verification results + if (schemaValid === false) { + console.log(`\nāŒ Credential verification failed\n`); + process.exit(1); + } + + console.log(`\nāœ… Credential verified successfully!\n`); + + console.log(`šŸ“Š Verification Details:`); + + // Extract issuer DID - handle both string and object formats + const issuerDid = typeof credential.issuer === 'string' + ? credential.issuer + : credential.issuer.id; + console.log(` āœ“ Issuer: ${issuerDid}`); + + // Display credential type(s) + const credentialTypes = Array.isArray(credential.type) + ? credential.type.filter((t: string) => t !== "VerifiableCredential").join(", ") + : credential.type; + console.log(` āœ“ Type: ${credentialTypes}`); + + // Display validity dates + if (credential.validFrom) { + const validFromMoment = moment(credential.validFrom); + console.log(` āœ“ Valid From: ${validFromMoment.fromNow()}`); + } else if (credential.nbf) { + const validFromDate = new Date(credential.nbf * 1000); + const validFromMoment = moment(validFromDate); + console.log(` āœ“ Valid From: ${validFromMoment.fromNow()}`); + } + + if (credential.validUntil) { + const validUntilMoment = moment(credential.validUntil); + console.log(` āœ“ Valid Until: ${validUntilMoment.fromNow()}`); + } else if (credential.exp) { + const validUntilDate = new Date(credential.exp * 1000); + const validUntilMoment = moment(validUntilDate); + console.log(` āœ“ Valid Until: ${validUntilMoment.fromNow()}`); + } + + // Display signature status + console.log(` āœ“ Signature: ${signatureValid ? "Valid" : "Invalid"}`); + + // Display schema status if validated + if (schemaValid !== null) { + console.log(` āœ“ Schema: ${schemaValid ? "Valid" : "Invalid"}`); + } + + console.log(); +} diff --git a/examples/bun-toolkit/src/cli/commands/did-generate.ts b/examples/bun-toolkit/src/cli/commands/did-generate.ts new file mode 100644 index 0000000..b5babb1 --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/did-generate.ts @@ -0,0 +1,180 @@ +import { generatePrivateKey, exportPublicKey } from "../../key"; +import { expandPath } from "../../utils/path"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import type { GenerateOptions, Algorithm } from "../cli-utils"; + +interface DidDocument { + "@context": string[]; + id: string; + verificationMethod: Array<{ + id: string; + type: string; + controller: string; + publicKeyJwk: { + kty: string; + crv: string; + alg: string; + x: string; + y: string; + }; + }>; + assertionMethod: string[]; + authentication: string[]; + alsoKnownAs?: string[]; +} + +export async function generateDid(domainInput: string, options: GenerateOptions) { + console.log(`\nšŸ“‹ Command: did generate`); + console.log(`šŸ“ Input: ${domainInput}`); + + // Parse domain and path - convert slashes to colons for DID format + // contoso.com/organizations/123 -> domain: contoso.com, path: organizations:123 + const parts = domainInput.split("/"); + const domain = parts[0]; + const pathParts = parts.slice(1).filter(p => p.length > 0); + const didPath = pathParts.length > 0 ? `:${pathParts.join(":")}` : ""; + + if (pathParts.length > 0) { + console.log(`šŸ“ Domain: ${domain}`); + console.log(`šŸ“ Path: /${pathParts.join("/")}`); + } + + // Parse options + const algorithm = (options.algorithm || "ES256").toUpperCase() as Algorithm; + const lei = options.lei; + const customKeyPath = options["output-path"]; + + // Validate algorithm + if (algorithm !== "ES256" && algorithm !== "ES384") { + console.error(`\nāŒ Error: Unsupported algorithm "${algorithm}"`); + console.error(` Available algorithms: ES256, ES384`); + process.exit(1); + } + + console.log(`\nšŸ”‘ Generating cryptographic keys (${algorithm})...`); + + // Generate assertion key (for issuing credentials) + const assertionPrivateKey = await generatePrivateKey(algorithm); + const assertionPublicKey = await exportPublicKey(assertionPrivateKey); + + console.log(` āœ“ Assertion key generated`); + console.log(` Kid: ${assertionPrivateKey.kid}`); + + // Generate authentication key (for presenting credentials) + const authenticationPrivateKey = await generatePrivateKey(algorithm); + const authenticationPublicKey = await exportPublicKey(authenticationPrivateKey); + + console.log(` āœ“ Authentication key generated`); + console.log(` Kid: ${authenticationPrivateKey.kid}`); + + console.log(`\nšŸ“„ Building DID document...`); + + // Build the DID document (W3C DID standard format) + const didId = `did:web:${domain}${didPath}`; + + const didDocument: DidDocument = { + "@context": [ + "https://www.w3.org/ns/cid/v1", + ], + id: didId, + verificationMethod: [ + { + id: `${didId}#${assertionPublicKey.kid}`, + type: "JsonWebKey", + controller: didId, + publicKeyJwk: { + kty: assertionPublicKey.kty, + crv: assertionPublicKey.crv, + alg: assertionPublicKey.alg, + x: assertionPublicKey.x, + y: assertionPublicKey.y + } + }, + { + id: `${didId}#${authenticationPublicKey.kid}`, + type: "JsonWebKey", + controller: didId, + publicKeyJwk: { + kty: authenticationPublicKey.kty, + crv: authenticationPublicKey.crv, + alg: authenticationPublicKey.alg, + x: authenticationPublicKey.x, + y: authenticationPublicKey.y + } + } + ], + assertionMethod: [ + `${didId}#${assertionPublicKey.kid}` + ], + authentication: [ + `${didId}#${authenticationPublicKey.kid}` + ] + }; + + // Add LEI if provided + if (lei) { + didDocument.alsoKnownAs = [`urn:ietf:spice:glue:lei:${lei}`]; + } + + console.log(` āœ“ DID document created`); + + console.log(`\nšŸ’¾ Saving files...`); + + // Determine storage path - use Downloads if it exists, otherwise home directory + let baseDir: string; + if (customKeyPath) { + baseDir = expandPath(customKeyPath); + } else { + const homeDir = os.homedir(); + const downloadsDir = path.join(homeDir, "Downloads"); + + // Check if Downloads directory exists (common on macOS, Windows, Linux desktops) + if (fs.existsSync(downloadsDir)) { + baseDir = path.join(downloadsDir, "vc-cli"); + } else { + // Fallback to home directory if Downloads doesn't exist (e.g., Linux servers) + baseDir = path.join(homeDir, "vc-cli"); + } + } + + // Create safe directory name by replacing special characters + const safeDidName = `did-web-${domain}${didPath}`.replace(/:/g, "-"); + const keyPath = path.join(baseDir, safeDidName); + + // Create directory + if (!fs.existsSync(keyPath)) { + fs.mkdirSync(keyPath, { recursive: true }); + } + + // Save DID document + const didDocPath = path.join(keyPath, "did.json"); + await Bun.write(didDocPath, JSON.stringify(didDocument, null, 2)); + console.log(` āœ“ DID document saved: ${didDocPath}`); + + // Save private keys + const privateKeyPath = path.join(keyPath, "private-key.json"); + const privateKeys = { + assertion: assertionPrivateKey, + authentication: authenticationPrivateKey + }; + await Bun.write(privateKeyPath, JSON.stringify(privateKeys, null, 2)); + console.log(` āœ“ Private keys saved: ${privateKeyPath}`); + + // Save public keys + const publicKeyPath = path.join(keyPath, "public-key.json"); + const publicKeys = { + assertion: assertionPublicKey, + authentication: authenticationPublicKey + }; + await Bun.write(publicKeyPath, JSON.stringify(publicKeys, null, 2)); + console.log(` āœ“ Public keys saved: ${publicKeyPath}`); + + console.log(`\nšŸ“‹ DID Document:`); + console.log(JSON.stringify(didDocument, null, 2)); + + console.log(`\nāœ… Files saved successfully!\n`); + console.log(`Keys stored at:`); + console.log(` ${keyPath}\n`); +} diff --git a/examples/bun-toolkit/src/cli/commands/did-verify.ts b/examples/bun-toolkit/src/cli/commands/did-verify.ts new file mode 100644 index 0000000..f96f578 --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/did-verify.ts @@ -0,0 +1,104 @@ +import * as path from "path"; +import * as fs from "fs"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import * as yaml from "js-yaml"; +import type { VerifyOptions } from "../cli-utils"; + +export async function verifyDid(did: string, options: VerifyOptions) { + console.log(`\nšŸ“‹ Command: did verify`); + console.log(`šŸ” DID: ${did}\n`); + + const showDocument = options["show-document"] || false; + + // Parse the DID + if (!did.startsWith("did:web:")) { + console.error("āŒ Error: Invalid DID format. Expected format: did:web:[:]"); + process.exit(1); + } + + // Parse did:web according to the spec + // did:web:contoso.com -> https://contoso.com/.well-known/did.json + // did:web:contoso.com:user:alice -> https://contoso.com/user/alice/did.json + const didParts = did.replace("did:web:", "").split(":"); + const domain = didParts[0]; + const pathParts = didParts.slice(1); + + let url: string; + if (pathParts.length > 0) { + // Has path components - construct URL with path + const path = pathParts.join("/"); + url = `https://${domain}/${path}/did.json`; + } else { + // No path components - use .well-known + url = `https://${domain}/.well-known/did.json`; + } + + console.log(`šŸ“ Domain: ${domain}`); + if (pathParts.length > 0) { + console.log(`šŸ“ Path: /${pathParts.join("/")}`); + } + console.log(`🌐 Fetching: ${url}\n`); + + try { + // Fetch the DID document + const response = await fetch(url); + + if (!response.ok) { + console.error(`āŒ Failed to fetch DID document: ${response.status} ${response.statusText}`); + process.exit(1); + } + + console.log(`āœ… HTTP ${response.status} - DID document found`); + + // Parse JSON + let didDocument: any; + try { + didDocument = await response.json(); + console.log(`āœ… Valid JSON structure`); + } catch (jsonError) { + console.error(`\nāŒ Invalid JSON: ${jsonError}`); + process.exit(1); + } + + // Validate against DID document schema + const schemaPath = path.resolve(__dirname, "../../../schemas/did-document.yaml"); + const schemaContent = fs.readFileSync(schemaPath, "utf8"); + const schema = yaml.load(schemaContent) as any; + + const ajv = new Ajv({ allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(schema); + + const valid = validate(didDocument); + + if (!valid) { + console.error(`\nāŒ Invalid DID document structure:`); + validate.errors?.forEach(err => { + console.error(` - ${err.instancePath || "root"}: ${err.message}`); + }); + process.exit(1); + } + + console.log(`āœ… Valid DID document structure`); + console.log(` - ID: ${didDocument.id}`); + if (didDocument.verificationMethod) { + console.log(` - Verification methods: ${didDocument.verificationMethod.length}`); + } + if (didDocument.alsoKnownAs) { + console.log(` - Also known as: ${didDocument.alsoKnownAs.length} identifier(s)`); + } + + console.log(`\nāœ… DID verification successful!`); + + if (showDocument) { + console.log(`\nDID Document:`); + console.log(JSON.stringify(didDocument, null, 2)); + } + console.log(); + + } catch (error) { + console.error(`\nāŒ Error verifying DID: ${error}`); + process.exit(1); + } +} diff --git a/examples/bun-toolkit/src/cli/commands/presentation-create.ts b/examples/bun-toolkit/src/cli/commands/presentation-create.ts new file mode 100644 index 0000000..7af29be --- /dev/null +++ b/examples/bun-toolkit/src/cli/commands/presentation-create.ts @@ -0,0 +1,224 @@ +import { expandPath } from "../../utils/path"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { loadFromEnv } from "../../config/env"; +import { signer } from "../../presentation/signer"; +import { createEnvelopedVerifiableCredential } from "../../credential/credential"; +import { credentialVerifierFromResolver } from "../../credential/credentialVerifierFromResolver"; +import type { VerifiablePresentation } from "../../presentation/presentation"; +import type { CreatePresentationOptions } from "../cli-utils"; + +export async function createPresentation(credentialFiles: string[], options: CreatePresentationOptions) { + console.log(`\nšŸ“‹ Command: presentation create`); + console.log(`šŸ“„ Credential paths: ${credentialFiles.length}\n`); + + // Step 1: Load and validate environment configuration + console.log(`šŸ”§ Loading configuration...`); + const config = loadFromEnv(); + + // Override with command-line options if provided + const holderDid = options.holder || config.issuerDid; + const privateKeyPath = options["private-key"] + ? expandPath(options["private-key"]) + : config.privateKeyPath; + + if (!holderDid) { + console.error(`\nāŒ Configuration error - holder DID not specified.`); + console.error(`Please set VC_CLI_ISSUER_DID in .env or use --holder flag.\n`); + process.exit(1); + } + + if (!privateKeyPath) { + console.error(`\nāŒ Configuration error - private key path not specified.`); + console.error(`Please set VC_CLI_PRIVATE_KEY_PATH in .env or use --private-key flag.\n`); + process.exit(1); + } + + console.log(` āœ“ Configuration loaded`); + console.log(` Holder: ${holderDid}`); + + // Step 2: Load private key + if (!fs.existsSync(privateKeyPath)) { + console.error(`\nāŒ Private key file not found: ${privateKeyPath}\n`); + process.exit(1); + } + + const privateKeyContent = fs.readFileSync(privateKeyPath, "utf-8"); + const privateKeys = JSON.parse(privateKeyContent); + + // Use authentication key for presentations (not assertion key) + const authenticationKey = privateKeys.authentication; + if (!authenticationKey) { + console.error(`\nāŒ Authentication key not found in private key file.\n`); + process.exit(1); + } + + // Step 3: Load and validate credential files (supports files and directories) + console.log(`\nšŸ“ Loading credentials...`); + const credentialJWTs: string[] = []; + const allCredentialPaths: string[] = []; + + // Process each argument - could be a file or directory + for (const credentialFileOrDir of credentialFiles) { + const inputPath = expandPath(credentialFileOrDir); + + if (!fs.existsSync(inputPath)) { + console.error(`\nāŒ Path not found: ${inputPath}\n`); + process.exit(1); + } + + const stats = fs.statSync(inputPath); + + if (stats.isDirectory()) { + // Scan directory for .vc.jwt.txt files (non-recursive) + const files = fs.readdirSync(inputPath); + const vcFiles = files + .filter(f => f.endsWith('.vc.jwt.txt')) + .map(f => path.join(inputPath, f)); + + if (vcFiles.length === 0) { + console.error(`\nāŒ No credential files (*.vc.jwt.txt) found in directory: ${inputPath}\n`); + process.exit(1); + } + + console.log(` āœ“ Found ${vcFiles.length} credential(s) in ${path.basename(inputPath)}`); + allCredentialPaths.push(...vcFiles); + } else { + // It's a file + allCredentialPaths.push(inputPath); + } + } + + // Now load all the credentials + for (const credentialPath of allCredentialPaths) { + try { + const credentialJWT = fs.readFileSync(credentialPath, "utf-8").trim(); + credentialJWTs.push(credentialJWT); + console.log(` āœ“ Loaded: ${path.basename(credentialPath)}`); + } catch (error) { + console.error(`\nāŒ Failed to read credential file: ${credentialPath}\n`); + console.error(` Error: ${error}\n`); + process.exit(1); + } + } + + // Step 4: Validate credentials before packaging + console.log(`\nšŸ” Validating credentials...`); + const verifier = await credentialVerifierFromResolver(); + const validatedCredentials: any[] = []; + + for (let i = 0; i < credentialJWTs.length; i++) { + const credentialJWT = credentialJWTs[i]; + const credentialPath = allCredentialPaths[i]; + + if (!credentialJWT || !credentialPath) { + console.error(`\nāŒ Missing credential data at index ${i}\n`); + process.exit(1); + } + + try { + // Verify signature and DID resolution + const credential = await verifier.verify(credentialJWT); + + // Check expiration date + if (credential.exp) { + const expirationDate = new Date(credential.exp * 1000); + const now = new Date(); + + if (expirationDate < now) { + console.error(`\nāŒ Credential expired: ${path.basename(credentialPath)}`); + console.error(` Expired on: ${expirationDate.toISOString()}\n`); + process.exit(1); + } + } + + // Check validity start date + if (credential.nbf) { + const validFromDate = new Date(credential.nbf * 1000); + const now = new Date(); + + if (validFromDate > now) { + console.error(`\nāŒ Credential not yet valid: ${path.basename(credentialPath)}`); + console.error(` Valid from: ${validFromDate.toISOString()}\n`); + process.exit(1); + } + } + + validatedCredentials.push(credential); + console.log(` āœ“ Valid: ${path.basename(credentialPath)}`); + } catch (error) { + console.error(`\nāŒ Credential verification failed: ${path.basename(credentialPath)}`); + console.error(` Error: ${error}\n`); + process.exit(1); + } + } + + // Step 5: Create enveloped credentials + console.log(`\nšŸ“¦ Creating enveloped credentials...`); + const envelopedCredentials = credentialJWTs.map(jwt => createEnvelopedVerifiableCredential(jwt)); + console.log(` āœ“ Created ${envelopedCredentials.length} enveloped credential(s)`); + + // Step 6: Create presentation + console.log(`\nšŸŽ­ Creating verifiable presentation...`); + const presentation: VerifiablePresentation = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + type: ["VerifiablePresentation"], + holder: holderDid, + verifiableCredential: envelopedCredentials + }; + + // Step 7: Sign the presentation + console.log(`\nšŸ” Signing presentation...`); + const presentationSigner = await signer(authenticationKey); + + // Build signing options + const signingOptions: any = { + kid: authenticationKey.kid + }; + + // Handle expires-in option + if (options["expires-in"]) { + const expiresIn = parseInt(options["expires-in"]); + const now = Math.floor(Date.now() / 1000); + signingOptions.iat = now; + signingOptions.exp = now + expiresIn; + } + + const signedPresentation = await presentationSigner.sign(presentation, signingOptions); + console.log(` āœ“ Presentation signed`); + + // Step 8: Save the signed presentation + console.log(`\nšŸ’¾ Saving presentation...`); + + // Determine storage path + let outputDir: string; + if (options["output-path"]) { + outputDir = expandPath(options["output-path"]); + } else { + const homeDir = os.homedir(); + const downloadsDir = path.join(homeDir, "Downloads"); + + // Check if Downloads directory exists + if (fs.existsSync(downloadsDir)) { + outputDir = path.join(downloadsDir, "vc-cli", "presentations"); + } else { + outputDir = path.join(homeDir, "vc-cli", "presentations"); + } + } + + // Create output directory if it doesn't exist + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Generate filename + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5); + const outputFileName = `presentation-${timestamp}.vp.jwt.txt`; + const outputPath = path.join(outputDir, outputFileName); + + await Bun.write(outputPath, signedPresentation); + console.log(` āœ“ Presentation: ${outputPath}`); + + console.log(`\nāœ… Presentation creation complete!\n`); +} diff --git a/examples/bun-toolkit/src/config/env.ts b/examples/bun-toolkit/src/config/env.ts new file mode 100644 index 0000000..2a6621e --- /dev/null +++ b/examples/bun-toolkit/src/config/env.ts @@ -0,0 +1,66 @@ +import { expandPath } from "../utils/path"; +import type { LLMProviderType } from "../llm/providers/LLMProvider"; + +/** + * Configuration loaded from environment variables + */ +export interface EnvironmentConfig { + // LLM Provider + llmProvider: LLMProviderType; + llmApiKey?: string; + + // Azure OpenAI specific + azureOpenAIEndpoint?: string; + azureOpenAIDeploymentName?: string; + + // Issuer identity + issuerDid?: string; + privateKeyPath?: string; + + // Output settings + outputDir?: string; +} + +/** + * Load configuration from environment variables + */ +export function loadFromEnv(): EnvironmentConfig { + const provider = (process.env.LLM_PROVIDER?.toLowerCase() as LLMProviderType) || "claude"; + + return { + llmProvider: provider, + llmApiKey: process.env.LLM_API_KEY, + azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT, + azureOpenAIDeploymentName: process.env.AZURE_OPENAI_DEPLOYMENT_NAME, + issuerDid: process.env.VC_CLI_ISSUER_DID, + privateKeyPath: process.env.VC_CLI_PRIVATE_KEY_PATH + ? expandPath(process.env.VC_CLI_PRIVATE_KEY_PATH) + : undefined, + outputDir: process.env.VC_CLI_OUTPUT_DIR || "./credentials", + }; +} + +/** + * Validate required configuration for credential issuance + */ +export function validateCredentialConfig(config: EnvironmentConfig): { + valid: boolean; + missing: string[]; +} { + const missing: string[] = []; + + if (!config.llmApiKey) { + missing.push("LLM_API_KEY"); + } + if (!config.issuerDid) { + missing.push("VC_CLI_ISSUER_DID"); + } + if (!config.privateKeyPath) { + missing.push("VC_CLI_PRIVATE_KEY_PATH"); + } + + return { + valid: missing.length === 0, + missing + }; +} diff --git a/examples/bun-toolkit/src/credential/credentialVerifierFromResolver.ts b/examples/bun-toolkit/src/credential/credentialVerifierFromResolver.ts index b70a0b0..4b46d91 100644 --- a/examples/bun-toolkit/src/credential/credentialVerifierFromResolver.ts +++ b/examples/bun-toolkit/src/credential/credentialVerifierFromResolver.ts @@ -22,10 +22,19 @@ export const credentialVerifierFromResolver = async ( throw new Error('Credential must have an issuer'); } + // Extract issuer DID - handle both string and object formats + const issuerDid = typeof credential.issuer === 'string' + ? credential.issuer + : credential.issuer.id; + + if (!issuerDid) { + throw new Error('Credential issuer must have an id'); + } + const assertionKeyId = header.kid; - if (!assertionKeyId.startsWith(credential.issuer)) { - throw new Error(`Credential issuer ${credential.issuer} does not match assertion key ${assertionKeyId}`); + if (!assertionKeyId.startsWith(issuerDid)) { + throw new Error(`Credential issuer ${issuerDid} does not match assertion key ${assertionKeyId}`); } // Resolve the issuer's controller document diff --git a/examples/bun-toolkit/src/credential/issuer.ts b/examples/bun-toolkit/src/credential/issuer.ts new file mode 100644 index 0000000..47bc4d2 --- /dev/null +++ b/examples/bun-toolkit/src/credential/issuer.ts @@ -0,0 +1,61 @@ +import * as fs from "fs"; +import type { VerifiableCredential } from "./credential"; +import { signer } from "./signer"; +import type { PrivateKey } from "../types"; + +export interface IssueOptions { + validFrom?: string; + validUntil?: string; +} + +/** + * Issue a verifiable credential from extracted document data + * @param issuerDid The DID of the credential issuer + * @param privateKeyPath Path to the private key file + * @param credentialTypes Array of credential types (e.g., ["VerifiableCredential", "MetallurgicalCertificationCredential"]) + * @param credentialSubjectData The extracted data to include in credentialSubject + * @param options Optional parameters (validFrom, validUntil) + * @returns Signed verifiable credential as an enveloped JWT string + */ +export async function issueCredential( + issuerDid: string, + privateKeyPath: string, + credentialTypes: string[], + credentialSubjectData: Record, + options: IssueOptions = {} +): Promise { + // Load private key + const privateKeyContent = fs.readFileSync(privateKeyPath, "utf-8"); + const privateKeys = JSON.parse(privateKeyContent); + + // Use assertion key for signing credentials + const assertionKey = privateKeys.assertion as PrivateKey; + + if (!assertionKey) { + throw new Error("Assertion key not found in private key file"); + } + + // Build the verifiable credential + const credential: VerifiableCredential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + type: credentialTypes, + issuer: issuerDid, + credentialSubject: credentialSubjectData, + }; + + // Add optional validity dates + if (options.validFrom) { + credential.validFrom = options.validFrom; + } + if (options.validUntil) { + credential.validUntil = options.validUntil; + } + + // Sign the credential + const credentialSigner = await signer(assertionKey); + const jws = await credentialSigner.sign(credential, { + kid: `${issuerDid}#${assertionKey.kid}`, + }); + + return jws; +} diff --git a/examples/bun-toolkit/src/key/verifier.ts b/examples/bun-toolkit/src/key/verifier.ts index 6bf69ea..e655e7c 100644 --- a/examples/bun-toolkit/src/key/verifier.ts +++ b/examples/bun-toolkit/src/key/verifier.ts @@ -9,7 +9,7 @@ export async function verifier(publicKey: PublicKey): Promise { const cryptoKey = await crypto.subtle.importKey("jwk", publicKey, { name: fullySpecifiedAlgorithms[publicKey.alg].name, namedCurve: fullySpecifiedAlgorithms[publicKey.alg].namedCurve - }, true, publicKey.key_ops); + }, true, publicKey.key_ops || ["verify"]); return { verify: async (data: Uint8Array, signature: Uint8Array) => { diff --git a/examples/bun-toolkit/src/llm/llm-client.ts b/examples/bun-toolkit/src/llm/llm-client.ts new file mode 100644 index 0000000..23a798f --- /dev/null +++ b/examples/bun-toolkit/src/llm/llm-client.ts @@ -0,0 +1,384 @@ +/** + * LLM client for document processing + * Supports multiple LLM providers (Claude, Gemini, OpenAI) + */ + +import { getStructureExtractionPrompt, DOCUMENT_TO_MARKDOWN_PROMPT } from "./prompts"; +import * as fs from "fs"; +import * as path from "path"; +import { pdfToPng } from "pdf-to-png-converter"; +import type { LLMProvider, LLMProviderType } from "./providers/LLMProvider"; +import { ClaudeProvider } from "./providers/ClaudeProvider"; +import { GeminiProvider } from "./providers/GeminiProvider"; +import { OpenAIProvider } from "./providers/OpenAIProvider"; +import { AzureOpenAIProvider } from "./providers/AzureOpenAIProvider"; + +/** + * LLM client configuration + */ +export interface LLMClientConfig { + provider: LLMProviderType; + apiKey: string; + // Azure OpenAI specific configuration + azureEndpoint?: string; + azureDeploymentName?: string; +} + +/** + * Supported image file extensions for vision API + */ +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; + +/** + * Text-based file extensions that can be converted directly + */ +const TEXT_EXTENSIONS = [".txt", ".json", ".yaml", ".yml", ".csv", ".md"]; + +/** + * Get the appropriate LLM provider based on configuration + */ +function getProvider(config: LLMClientConfig): LLMProvider { + switch (config.provider) { + case "claude": + return new ClaudeProvider({ + apiKey: config.apiKey, + }); + + case "gemini": + return new GeminiProvider({ + apiKey: config.apiKey, + }); + + case "openai": + return new OpenAIProvider({ + apiKey: config.apiKey, + }); + + case "azure-openai": + if (!config.azureEndpoint || !config.azureDeploymentName) { + throw new Error("Azure OpenAI requires azureEndpoint and azureDeploymentName in config"); + } + return new AzureOpenAIProvider({ + apiKey: config.apiKey, + endpoint: config.azureEndpoint, + deploymentName: config.azureDeploymentName, + }); + + default: + throw new Error(`Unsupported LLM provider: ${config.provider}`); + } +} + +/** + * Extract markdown from a document using LLM Vision or text processing + * @param documentPath Path to the document file + * @param config LLM client configuration + * @returns Extracted markdown content + */ +export async function extractMarkdown( + documentPath: string, + config: LLMClientConfig +): Promise { + const ext = path.extname(documentPath).toLowerCase(); + + // Check if file exists + if (!fs.existsSync(documentPath)) { + throw new Error(`Document file not found: ${documentPath}`); + } + + // Handle text-based formats - convert directly to markdown + if (TEXT_EXTENSIONS.includes(ext)) { + return convertTextToMarkdown(documentPath, ext); + } + + // Handle PDF - use Vision API + if (ext === ".pdf") { + return extractFromPdfVision(documentPath, config); + } + + // Handle images - use Vision API + if (IMAGE_EXTENSIONS.includes(ext)) { + return extractFromImageVision(documentPath, config); + } + + // Unsupported format + throw new Error( + `Unsupported file format: ${ext}\n` + + `Supported formats:\n` + + ` Images: ${IMAGE_EXTENSIONS.join(", ")}\n` + + ` Documents: .pdf\n` + + ` Text: ${TEXT_EXTENSIONS.join(", ")}` + ); +} + +/** + * Convert text-based files to markdown format + */ +function convertTextToMarkdown(filePath: string, ext: string): string { + const content = fs.readFileSync(filePath, "utf-8"); + + switch (ext) { + case ".md": + // Already markdown + return content; + + case ".txt": + // Plain text - just return with basic formatting + return content; + + case ".json": + // Convert JSON to formatted markdown + return convertJsonToMarkdown(content); + + case ".yaml": + case ".yml": + // Convert YAML to formatted markdown + return convertYamlToMarkdown(content); + + case ".csv": + // Convert CSV to markdown table + return convertCsvToMarkdown(content); + + default: + return content; + } +} + +/** + * Convert JSON to markdown + */ +function convertJsonToMarkdown(jsonContent: string): string { + try { + const data = JSON.parse(jsonContent); + let markdown = "# JSON Document\n\n"; + markdown += "```json\n"; + markdown += JSON.stringify(data, null, 2); + markdown += "\n```\n"; + return markdown; + } catch (error) { + throw new Error(`Failed to parse JSON: ${error}`); + } +} + +/** + * Convert YAML to markdown + */ +function convertYamlToMarkdown(yamlContent: string): string { + let markdown = "# YAML Document\n\n"; + markdown += "```yaml\n"; + markdown += yamlContent; + markdown += "\n```\n"; + return markdown; +} + +/** + * Convert CSV to markdown table + */ +function convertCsvToMarkdown(csvContent: string): string { + const lines = csvContent.trim().split("\n"); + if (lines.length === 0) { + return ""; + } + + let markdown = "# CSV Document\n\n"; + + // Parse CSV (simple implementation - doesn't handle quoted commas) + const rows = lines.map(line => line.split(",").map(cell => cell.trim())); + + // Header row + const headerRow = rows[0]; + if (headerRow) { + markdown += "| " + headerRow.join(" | ") + " |\n"; + markdown += "| " + headerRow.map(() => "---").join(" | ") + " |\n"; + } + + // Data rows + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + if (row) { + markdown += "| " + row.join(" | ") + " |\n"; + } + } + + return markdown; +} + +/** + * Extract markdown from image using LLM Vision + */ +async function extractFromImageVision( + imagePath: string, + config: LLMClientConfig +): Promise { + const provider = getProvider(config); + return provider.extractFromImage(imagePath, DOCUMENT_TO_MARKDOWN_PROMPT); +} + +/** + * Extract markdown from PDF by converting to images and using Vision API + * 1. Convert PDF pages to PNG images + * 2. Send each image to LLM Vision API + * 3. Combine results from all pages + */ +async function extractFromPdfVision( + pdfPath: string, + config: LLMClientConfig +): Promise { + const provider = getProvider(config); + + // Convert PDF to PNG images (one per page) + const pngPages = await pdfToPng(pdfPath, { + outputFolder: "/tmp/pdf-conversion", + viewportScale: 2.0, + }); + + if (!pngPages || pngPages.length === 0) { + throw new Error("Failed to convert PDF to images"); + } + + try { + // Process each page with Vision API + const pageMarkdowns: string[] = []; + + for (let i = 0; i < pngPages.length; i++) { + const page = pngPages[i]; + if (!page) continue; + + // Write temporary file for this page + const tempPath = `/tmp/pdf-page-${i}.png`; + fs.writeFileSync(tempPath, page.content); + + try { + const prompt = `${DOCUMENT_TO_MARKDOWN_PROMPT}\n\nThis is page ${i + 1} of ${pngPages.length}.`; + const markdown = await provider.extractFromImage(tempPath, prompt); + pageMarkdowns.push(markdown); + } finally { + // Clean up temp page file + try { + fs.unlinkSync(tempPath); + } catch (err) { + // Ignore cleanup errors + } + } + } + + // Combine all pages with page breaks + return pageMarkdowns.join("\n\n---\n\n"); + } finally { + // Clean up temporary PNG files from pdf-to-png-converter + for (const page of pngPages) { + try { + fs.unlinkSync(page.path); + } catch (err) { + // Ignore cleanup errors - file may not exist or already deleted + } + } + + // Try to remove the temp directory if it's empty + try { + fs.rmdirSync("/tmp/pdf-conversion"); + } catch (err) { + // Directory not empty or doesn't exist - ignore + } + } +} + +/** + * Extract structured data from markdown using LLM + * @param markdown Markdown content + * @param config LLM client configuration + * @returns Structured extraction result + */ +export async function extractStructure( + markdown: string, + config: LLMClientConfig +): Promise { + const provider = getProvider(config); + const prompt = getStructureExtractionPrompt(markdown); + + const responseText = await provider.extractFromText(prompt); + + // Parse the JSON response + let result: StructureExtractionResult; + try { + // LLM may wrap JSON in markdown code blocks, so extract it + let jsonText = responseText; + const jsonMatch = jsonText.match(/```json\n([\s\S]*?)\n```/); + if (jsonMatch && jsonMatch[1]) { + jsonText = jsonMatch[1]; + } + result = JSON.parse(jsonText); + } catch (error) { + throw new Error(`Failed to parse LLM response as JSON: ${error}\nResponse: ${responseText}`); + } + + // Validate the response has required fields + if (!result.documentType || !result.credentialTypes || !result.data || !result.schemaMetadata) { + throw new Error("Invalid response structure from LLM"); + } + + return result; +} + +/** + * Result of structure extraction from markdown + */ +export interface StructureExtractionResult { + documentType: string; + credentialTypes: string[]; + data: Record; + schemaMetadata: { + title: string; + description: string; + }; +} + +/** + * Generate example data from real extracted data + * Creates completely fictional/fake data that matches the structure but contains no real information + * @param realData The actual extracted data structure + * @param documentType The type of document (for context) + * @param config LLM client configuration + * @returns Example data with fictional values + */ +export async function generateExampleData( + realData: Record, + documentType: string, + config: LLMClientConfig +): Promise> { + const provider = getProvider(config); + + const prompt = `You are generating FICTIONAL example data for a JSON schema. + +Given this real data structure from a ${documentType}: +${JSON.stringify(realData, null, 2)} + +Generate a COMPLETE example that: +1. Has the EXACT SAME structure (same keys, same nesting, same array lengths) +2. Uses completely FICTIONAL/FAKE values (fake company names, fake IDs, fake numbers, fake dates) +3. For company/organization names: Use OBVIOUSLY fictional but professional names like "Example Steel Corp", "Sample Manufacturing Inc", "Test Industries LLC", "Demo Materials Co", etc. +4. For person names: Use OBVIOUSLY fictional names like "Jane Doe", "John Smith", "Bob Example", "Alice Sample", etc. +5. Keep all other fake data plausible (addresses, numbers, dates) +6. Maintains the same data types (strings stay strings, numbers stay numbers, etc.) +7. Returns ONLY the JSON object, no explanation + +Return the example data as a JSON object.`; + + const responseText = await provider.extractFromText(prompt); + + // Parse the JSON response + let exampleData: Record; + try { + // LLM may wrap JSON in markdown code blocks, so extract it + let jsonText = responseText; + const jsonMatch = jsonText.match(/```json\n([\s\S]*?)\n```/); + if (jsonMatch && jsonMatch[1]) { + jsonText = jsonMatch[1]; + } + exampleData = JSON.parse(jsonText); + } catch (error) { + throw new Error(`Failed to parse example data as JSON: ${error}\nResponse: ${responseText}`); + } + + return exampleData; +} diff --git a/examples/bun-toolkit/src/llm/prompts.ts b/examples/bun-toolkit/src/llm/prompts.ts new file mode 100644 index 0000000..dca4007 --- /dev/null +++ b/examples/bun-toolkit/src/llm/prompts.ts @@ -0,0 +1,57 @@ +/** + * AI prompt templates for document processing + */ + +/** + * Step 1: Convert document to Markdown + */ +export const DOCUMENT_TO_MARKDOWN_PROMPT = ` +Convert this document to well-structured Markdown format. + +Instructions: +1. Perform OCR to extract all text from the image/PDF +2. Preserve document structure (headings, tables, lists) +3. Use proper Markdown syntax: + - # for main headings + - ## for subheadings + - Tables with | syntax + - Lists with - or * +4. Preserve all data exactly as it appears +5. Include any visible metadata (dates, IDs, reference numbers) +6. Maintain spatial relationships (e.g., label-value pairs) + +Return only the Markdown content, no additional commentary. +`.trim(); + +/** + * Step 2: Extract structured data from Markdown + */ +export function getStructureExtractionPrompt(markdown: string): string { + return ` +Analyze this Markdown document and extract structured data. + +Instructions: +1. Identify the document type (e.g., "CommercialInvoice", "BillOfLading", "PurchaseOrder", "CertificateOfOrigin") +2. Extract all relevant data into a well-structured JSON object +3. Use semantic field names (camelCase) +4. Infer appropriate data types (string, number, boolean, arrays, objects) +5. Suggest W3C Verifiable Credential type names based on the document type +6. If the document has a natural identifier (invoice number, shipment ID, etc.), include it + +Return a JSON response with this exact structure: +{ + "documentType": "string (human-readable type)", + "credentialTypes": ["VerifiableCredential", "SpecificTypeCredential"], + "data": { + /* extracted structured data with semantic field names */ + }, + "schemaMetadata": { + "title": "string (descriptive schema title)", + "description": "string (what this schema represents)" + } +} + +Document content: +${markdown} +`.trim(); +} diff --git a/examples/bun-toolkit/src/llm/providers/AzureOpenAIProvider.ts b/examples/bun-toolkit/src/llm/providers/AzureOpenAIProvider.ts new file mode 100644 index 0000000..0ff591b --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/AzureOpenAIProvider.ts @@ -0,0 +1,99 @@ +/** + * Azure OpenAI provider implementation + */ + +import OpenAI from "openai"; +import type { LLMProvider, LLMConfig } from "./LLMProvider"; +import { readImageAsBase64, getMimeType } from "./llm-utils"; + +/** + * Azure OpenAI API version (hardcoded to avoid configuration complexity) + */ +const AZURE_OPENAI_API_VERSION = "2024-02-15-preview"; + +/** + * Azure OpenAI configuration + */ +export interface AzureOpenAIConfig extends LLMConfig { + endpoint: string; + deploymentName: string; +} + +/** + * Azure OpenAI provider for document extraction using Azure's OpenAI service + */ +export class AzureOpenAIProvider implements LLMProvider { + private config: AzureOpenAIConfig; + private client: OpenAI; + + constructor(config: AzureOpenAIConfig) { + this.config = config; + this.client = new OpenAI({ + apiKey: config.apiKey, + baseURL: `${config.endpoint}/openai/deployments/${config.deploymentName}`, + defaultQuery: { "api-version": AZURE_OPENAI_API_VERSION }, + defaultHeaders: { "api-key": config.apiKey }, + }); + } + + /** + * Extract markdown content from an image using Azure OpenAI Vision API + */ + async extractFromImage(imagePath: string, prompt: string): Promise { + const base64Image = readImageAsBase64(imagePath); + const mimeType = getMimeType(imagePath); + const dataUrl = `data:${mimeType};base64,${base64Image}`; + + const response = await this.client.chat.completions.create({ + model: this.config.deploymentName, + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: dataUrl, + }, + }, + { + type: "text", + text: prompt, + }, + ], + }, + ], + max_tokens: 4096, + }); + + const messageContent = response.choices[0]?.message?.content; + if (!messageContent) { + throw new Error("No text response from Azure OpenAI Vision API"); + } + + return messageContent; + } + + /** + * Extract structured data from text using Azure OpenAI + */ + async extractFromText(prompt: string): Promise { + const response = await this.client.chat.completions.create({ + model: this.config.deploymentName, + messages: [ + { + role: "user", + content: prompt, + }, + ], + max_tokens: 4096, + }); + + const messageContent = response.choices[0]?.message?.content; + if (!messageContent) { + throw new Error("No text response from Azure OpenAI"); + } + + return messageContent; + } +} diff --git a/examples/bun-toolkit/src/llm/providers/ClaudeProvider.ts b/examples/bun-toolkit/src/llm/providers/ClaudeProvider.ts new file mode 100644 index 0000000..167d042 --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/ClaudeProvider.ts @@ -0,0 +1,88 @@ +/** + * Claude (Anthropic) provider implementation + */ + +import Anthropic from "@anthropic-ai/sdk"; +import type { LLMProvider, LLMConfig } from "./LLMProvider"; +import { readImageAsBase64, getMimeType } from "./llm-utils"; + +/** + * Default Claude model + */ +export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-20250514"; + +/** + * Claude provider for document extraction using Anthropic's API + */ +export class ClaudeProvider implements LLMProvider { + private client: Anthropic; + + constructor(config: LLMConfig) { + this.client = new Anthropic({ + apiKey: config.apiKey, + }); + } + + /** + * Extract markdown content from an image using Claude Vision API + */ + async extractFromImage(imagePath: string, prompt: string): Promise { + const base64Image = readImageAsBase64(imagePath); + const mediaType = getMimeType(imagePath) as "image/jpeg" | "image/png" | "image/gif" | "image/webp"; + + // Call Claude Vision API + const message = await this.client.messages.create({ + model: DEFAULT_CLAUDE_MODEL, + max_tokens: 4096, + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64Image, + }, + }, + { + type: "text", + text: prompt, + }, + ], + }, + ], + }); + + const responseContent = message.content[0]; + if (!responseContent || responseContent.type !== "text") { + throw new Error("No text response from Claude Vision API"); + } + + return responseContent.text; + } + + /** + * Extract structured data from text using Claude + */ + async extractFromText(prompt: string): Promise { + const message = await this.client.messages.create({ + model: DEFAULT_CLAUDE_MODEL, + max_tokens: 4096, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + const responseContent = message.content[0]; + if (!responseContent || responseContent.type !== "text") { + throw new Error("No text response from Claude"); + } + + return responseContent.text; + } +} diff --git a/examples/bun-toolkit/src/llm/providers/GeminiProvider.ts b/examples/bun-toolkit/src/llm/providers/GeminiProvider.ts new file mode 100644 index 0000000..05b8ce8 --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/GeminiProvider.ts @@ -0,0 +1,70 @@ +/** + * Google Gemini provider implementation + */ + +import { GoogleGenerativeAI } from "@google/generative-ai"; +import type { LLMProvider, LLMConfig } from "./LLMProvider"; +import { readImageAsBase64, getMimeType } from "./llm-utils"; + +/** + * Default Gemini model + */ +export const DEFAULT_GEMINI_MODEL = "gemini-2.0-flash-exp"; + +/** + * Gemini provider for document extraction using Google's Generative AI API + */ +export class GeminiProvider implements LLMProvider { + private client: GoogleGenerativeAI; + + constructor(config: LLMConfig) { + this.client = new GoogleGenerativeAI(config.apiKey); + } + + /** + * Extract markdown content from an image using Gemini Vision API + */ + async extractFromImage(imagePath: string, prompt: string): Promise { + const model = this.client.getGenerativeModel({ model: DEFAULT_GEMINI_MODEL }); + + const base64Image = readImageAsBase64(imagePath); + const mimeType = getMimeType(imagePath); + + // Call Gemini Vision API + const result = await model.generateContent([ + { + inlineData: { + data: base64Image, + mimeType: mimeType, + }, + }, + prompt, + ]); + + const response = result.response; + const text = response.text(); + + if (!text) { + throw new Error("No text response from Gemini Vision API"); + } + + return text; + } + + /** + * Extract structured data from text using Gemini + */ + async extractFromText(prompt: string): Promise { + const model = this.client.getGenerativeModel({ model: DEFAULT_GEMINI_MODEL }); + + const result = await model.generateContent(prompt); + const response = result.response; + const text = response.text(); + + if (!text) { + throw new Error("No text response from Gemini"); + } + + return text; + } +} diff --git a/examples/bun-toolkit/src/llm/providers/LLMProvider.ts b/examples/bun-toolkit/src/llm/providers/LLMProvider.ts new file mode 100644 index 0000000..bafb990 --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/LLMProvider.ts @@ -0,0 +1,36 @@ +/** + * LLM Provider interface for document extraction + * + * Abstracts LLM-specific implementation details to support multiple providers + * (Claude, Azure OpenAI, etc.) + */ + +/** + * Supported LLM provider types + */ +export type LLMProviderType = "claude" | "gemini" | "openai" | "azure-openai"; + +export interface LLMConfig { + apiKey: string; + model?: string; +} + +/** + * Base interface for LLM providers + */ +export interface LLMProvider { + /** + * Extract markdown content from an image + * @param imagePath Path to the image file + * @param prompt The extraction prompt + * @returns Extracted markdown content + */ + extractFromImage(imagePath: string, prompt: string): Promise; + + /** + * Extract structured data from text/markdown + * @param prompt The extraction prompt + * @returns LLM response as string (typically JSON) + */ + extractFromText(prompt: string): Promise; +} diff --git a/examples/bun-toolkit/src/llm/providers/OpenAIProvider.ts b/examples/bun-toolkit/src/llm/providers/OpenAIProvider.ts new file mode 100644 index 0000000..38f69c0 --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/OpenAIProvider.ts @@ -0,0 +1,87 @@ +/** + * OpenAI provider implementation + */ + +import OpenAI from "openai"; +import type { LLMProvider, LLMConfig } from "./LLMProvider"; +import { readImageAsBase64, getMimeType } from "./llm-utils"; + +/** + * Default OpenAI model + */ +export const DEFAULT_OPENAI_MODEL = "gpt-4o"; + +/** + * OpenAI provider for document extraction using OpenAI's API + */ +export class OpenAIProvider implements LLMProvider { + private client: OpenAI; + + constructor(config: LLMConfig) { + this.client = new OpenAI({ + apiKey: config.apiKey, + }); + } + + /** + * Extract markdown content from an image using OpenAI Vision API + */ + async extractFromImage(imagePath: string, prompt: string): Promise { + const base64Image = readImageAsBase64(imagePath); + const mimeType = getMimeType(imagePath); + const dataUrl = `data:${mimeType};base64,${base64Image}`; + + // Call OpenAI Vision API + const response = await this.client.chat.completions.create({ + model: DEFAULT_OPENAI_MODEL, + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: dataUrl, + }, + }, + { + type: "text", + text: prompt, + }, + ], + }, + ], + max_tokens: 4096, + }); + + const messageContent = response.choices[0]?.message?.content; + if (!messageContent) { + throw new Error("No text response from OpenAI Vision API"); + } + + return messageContent; + } + + /** + * Extract structured data from text using OpenAI + */ + async extractFromText(prompt: string): Promise { + const response = await this.client.chat.completions.create({ + model: DEFAULT_OPENAI_MODEL, + messages: [ + { + role: "user", + content: prompt, + }, + ], + max_tokens: 4096, + }); + + const messageContent = response.choices[0]?.message?.content; + if (!messageContent) { + throw new Error("No text response from OpenAI"); + } + + return messageContent; + } +} diff --git a/examples/bun-toolkit/src/llm/providers/llm-utils.ts b/examples/bun-toolkit/src/llm/providers/llm-utils.ts new file mode 100644 index 0000000..ef2f61c --- /dev/null +++ b/examples/bun-toolkit/src/llm/providers/llm-utils.ts @@ -0,0 +1,29 @@ +/** + * Shared utilities for LLM providers + */ + +import * as fs from "fs"; +import * as path from "path"; + +/** + * Read an image file and convert to base64 + */ +export function readImageAsBase64(imagePath: string): string { + const imageBuffer = fs.readFileSync(imagePath); + return imageBuffer.toString("base64"); +} + +/** + * Get MIME type from file extension + */ +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + }; + return mimeTypes[ext] || "image/png"; +} diff --git a/examples/bun-toolkit/src/resolver/genericResolver.ts b/examples/bun-toolkit/src/resolver/genericResolver.ts index fb73ea8..2fb6c1e 100644 --- a/examples/bun-toolkit/src/resolver/genericResolver.ts +++ b/examples/bun-toolkit/src/resolver/genericResolver.ts @@ -1,10 +1,8 @@ import type { Controller } from "../controller/controller"; import type { PublicKey } from "../types"; import type { Schema } from "ajv"; -import { createControllerResolver } from "./controllerResolver"; -import { createSchemaResolver } from "./schemaResolver"; import { createPublicKeyResolver } from "./publicKeyResolver"; -import Ajv, { ValidateFunction } from "ajv"; +import Ajv, { type ValidateFunction } from "ajv"; import addFormats from "ajv-formats"; export interface GenericResolver { @@ -58,7 +56,43 @@ export const createGenericResolver = ( return { resolveController: async (id: string) => { - const controller = controllerLookup[id]; + let controller = controllerLookup[id]; + + // If not in cache, try to fetch did:web documents + if (!controller && id.startsWith('did:web:')) { + // Extract the DID from the key reference (remove fragment) + const did = id.split('#')[0]; + + if (did) { + // Convert did:web to HTTPS URL + // did:web:example.com -> https://example.com/.well-known/did.json + // did:web:example.com:path:to:doc -> https://example.com/path/to/doc/did.json + const didParts = did.replace('did:web:', '').split(':'); + const domain = didParts[0]; + const pathParts = didParts.slice(1); + + let url: string; + if (pathParts.length > 0) { + const path = pathParts.join('/'); + url = `https://${domain}/${path}/did.json`; + } else { + url = `https://${domain}/.well-known/did.json`; + } + + try { + const response = await fetch(url); + if (response.ok) { + const fetchedController = await response.json() as any; + controller = fetchedController; + // Cache it for future use + controllerLookup[did] = fetchedController; + } + } catch (error) { + // Fetch failed, controller will remain undefined + } + } + } + if (!controller) { throw new Error(`Controller not found for id: ${id}`); } @@ -66,12 +100,15 @@ export const createGenericResolver = ( const assertionKeys: Array<[string, PublicKey]> = []; const authenticationKeys: Array<[string, PublicKey]> = []; - for (const verificationMethod of controller.verificationMethod) { - if (controller.assertionMethod?.includes(verificationMethod.id)) { - assertionKeys.push([verificationMethod.id, verificationMethod.publicKeyJwk]); - } - if (controller.authentication?.includes(verificationMethod.id)) { - authenticationKeys.push([verificationMethod.id, verificationMethod.publicKeyJwk]); + // Safely iterate over verificationMethod array + if (controller.verificationMethod && Array.isArray(controller.verificationMethod)) { + for (const verificationMethod of controller.verificationMethod) { + if (controller.assertionMethod?.includes(verificationMethod.id)) { + assertionKeys.push([verificationMethod.id, verificationMethod.publicKeyJwk]); + } + if (controller.authentication?.includes(verificationMethod.id)) { + authenticationKeys.push([verificationMethod.id, verificationMethod.publicKeyJwk]); + } } } diff --git a/examples/bun-toolkit/src/schema/generator.ts b/examples/bun-toolkit/src/schema/generator.ts new file mode 100644 index 0000000..c0704aa --- /dev/null +++ b/examples/bun-toolkit/src/schema/generator.ts @@ -0,0 +1,222 @@ +/** + * JSON Schema generator for verifiable credentials + */ + +import * as yaml from "js-yaml"; +import { randomUUID } from "crypto"; + +export interface SchemaMetadata { + title: string; + description: string; +} + +export interface GenerateSchemaOptions { + credentialTypes: string[]; + data: Record; + metadata: SchemaMetadata; + schemaId?: string; + exampleData?: Record; +} + +export interface GeneratedSchema { + yaml: string; + schemaId: string; +} + +/** + * Generate a JSON Schema (in YAML format) from extracted data + * Follows W3C VC v2 structure with dynamic credentialSubject based on extracted data + * @param options Schema generation options + * @returns Generated schema with ID + */ +export function generateYamlSchema( + options: GenerateSchemaOptions +): GeneratedSchema { + const schemaId = options.schemaId || generateSchemaId(); + + // Build the JSON Schema object + const schema: any = { + $id: schemaId, + title: options.metadata.title, + description: options.metadata.description, + type: "object", + required: ["@context", "type", "issuer", "credentialSubject"], + properties: { + "@context": { + type: "array", + items: { type: "string" }, + minItems: 1, + contains: { + const: "https://www.w3.org/ns/credentials/v2" + } + }, + type: { + type: "array", + items: { type: "string" }, + minItems: options.credentialTypes.length, + contains: { + enum: options.credentialTypes + } + }, + issuer: { + type: "string", + format: "uri", + description: "DID of the credential issuer" + }, + validFrom: { + type: "string", + format: "date-time", + description: "When the credential becomes valid" + }, + validUntil: { + type: "string", + format: "date-time", + description: "When the credential expires (optional)" + }, + credentialSchema: { + type: "array", + items: { + type: "object", + required: ["id", "type"], + properties: { + id: { + type: "string", + format: "uri" + }, + type: { + type: "string", + enum: ["JsonSchema"] + } + } + } + }, + credentialSubject: { + type: "object", + description: "The credential data extracted from the source document", + properties: inferSchemaProperties(options.data), + required: Object.keys(options.data) + } + } + }; + + // Add examples at root level if provided + if (options.exampleData) { + schema.examples = [ + { + "@context": ["https://www.w3.org/ns/credentials/v2"], + type: options.credentialTypes, + issuer: "did:web:example.com", + validFrom: "2025-01-01T00:00:00Z", + credentialSubject: options.exampleData + } + ]; + } + + // Convert to YAML with proper formatting + const yamlString = yaml.dump(schema, { + indent: 2, + lineWidth: 120, + noRefs: true, + sortKeys: false + }); + + return { + yaml: yamlString, + schemaId + }; +} + +/** + * Infer JSON Schema properties from data structure + * @param data The data object to analyze + * @returns JSON Schema properties definition + */ +function inferSchemaProperties(data: Record): Record { + const properties: Record = {}; + + for (const [key, value] of Object.entries(data)) { + properties[key] = inferSchemaType(value); + } + + return properties; +} + +/** + * Infer JSON Schema type from a value + * @param value The value to analyze + * @returns JSON Schema type definition + */ +function inferSchemaType(value: unknown): any { + if (value === null || value === undefined) { + return { type: "null" }; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { type: "array", items: {} }; + } + // Infer items schema from first element + return { + type: "array", + items: inferSchemaType(value[0]) + }; + } + + if (typeof value === "object") { + return { + type: "object", + properties: inferSchemaProperties(value as Record), + required: Object.keys(value as Record) + }; + } + + if (typeof value === "number") { + return { type: "number" }; + } + + if (typeof value === "boolean") { + return { type: "boolean" }; + } + + if (typeof value === "string") { + // Check if it looks like a date + if (isISODate(value)) { + return { type: "string", format: "date-time" }; + } + // Check if it looks like a URI + if (isURI(value)) { + return { type: "string", format: "uri" }; + } + return { type: "string" }; + } + + return {}; +} + +/** + * Check if a string looks like an ISO date + */ +function isISODate(str: string): boolean { + const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/; + return isoDatePattern.test(str); +} + +/** + * Check if a string looks like a URI + */ +function isURI(str: string): boolean { + try { + new URL(str); + return true; + } catch { + return false; + } +} + +/** + * Generate a UUID identifier for the schema + * @returns UUID in urn:uuid: format + */ +export function generateSchemaId(): string { + return `urn:uuid:${randomUUID()}`; +} diff --git a/examples/bun-toolkit/src/utils/path.ts b/examples/bun-toolkit/src/utils/path.ts new file mode 100644 index 0000000..173c3ce --- /dev/null +++ b/examples/bun-toolkit/src/utils/path.ts @@ -0,0 +1,14 @@ +import * as path from "path"; +import * as os from "os"; + +/** + * Expand ~ to home directory in paths + * @param filepath Path that may contain ~ + * @returns Expanded absolute path + */ +export function expandPath(filepath: string): string { + if (filepath.startsWith("~/")) { + return path.join(os.homedir(), filepath.slice(2)); + } + return filepath; +}