omnivault

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 19, 2026 License: MIT Imports: 10 Imported by: 0

README

OmniVault

Build Status Lint Status Go Report Card Docs License

OmniVault is a unified Go library for secret management across multiple providers. It provides a single interface for accessing secrets from password managers, cloud secret managers, enterprise vaults, and local storage.

Features

  • Unified Interface: Single API for all secret storage backends
  • Extensible Architecture: Add custom providers as separate Go modules without modifying the core library
  • URI-Based Resolution: Reference secrets using URIs like op://vault/item/field or aws-sm://secret-name
  • Built-in Providers: Environment variables, file-based, and in-memory storage included
  • Zero External Dependencies: Core library has no external dependencies beyond the standard library
  • CLI Tool: Command-line interface with encrypted local storage and daemon architecture
  • Secure Local Storage: AES-256-GCM encryption with Argon2id key derivation

Installation

Go Library
go get github.com/agentplexus/omnivault
CLI Tool
go install github.com/agentplexus/omnivault/cmd/omnivault@latest

Quick Start

Basic Usage
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/agentplexus/omnivault"
)

func main() {
    ctx := context.Background()

    // Create client with environment variable provider
    client, err := omnivault.NewClient(omnivault.Config{
        Provider: omnivault.ProviderEnv,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // Get a secret
    secret, err := client.Get(ctx, "API_KEY")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("API Key:", secret.Value)
}
Multi-Provider Resolution
package main

import (
    "context"
    "fmt"

    "github.com/agentplexus/omnivault"
    "github.com/agentplexus/omnivault/providers/env"
    "github.com/agentplexus/omnivault/providers/memory"
)

func main() {
    ctx := context.Background()

    // Create resolver with multiple providers
    resolver := omnivault.NewResolver()
    resolver.Register("env", env.New())
    resolver.Register("memory", memory.NewWithSecrets(map[string]string{
        "database/password": "secret123",
    }))

    // Resolve secrets from different providers using URIs
    apiKey, _ := resolver.Resolve(ctx, "env://API_KEY")
    dbPass, _ := resolver.Resolve(ctx, "memory://database/password")

    fmt.Println("API Key:", apiKey)
    fmt.Println("DB Password:", dbPass)
}
Using Official Provider Modules
package main

import (
    "context"
    "fmt"

    "github.com/agentplexus/omnivault"
    aws "github.com/agentplexus/omnivault-aws"
    "github.com/agentplexus/omnivault-keyring"
)

func main() {
    ctx := context.Background()

    // Create providers
    awsProvider, _ := aws.NewSecretsManager(aws.Config{Region: "us-east-1"})
    keyringProvider := keyring.New(keyring.Config{ServiceName: "myapp"})

    // Multi-provider resolver
    resolver := omnivault.NewResolver()
    resolver.Register("aws-sm", awsProvider)
    resolver.Register("keyring", keyringProvider)

    // Resolve from AWS Secrets Manager
    dbCreds, _ := resolver.Resolve(ctx, "aws-sm://prod/database")

    // Resolve from OS keyring
    localToken, _ := resolver.Resolve(ctx, "keyring://dev/api-token")

    fmt.Println("DB Credentials:", dbCreds)
    fmt.Println("Local Token:", localToken)
}

Supported Providers

Built-in Providers
Provider Scheme Description
Environment Variables env:// Read from os.Getenv()
File file:// File-based storage
Memory memory:// In-memory storage (for testing)
Official Provider Modules

First-party provider modules maintained alongside OmniVault:

Module Providers Schemes
omnivault-aws AWS Secrets Manager, AWS Parameter Store aws-sm://, aws-ssm://
omnivault-keyring macOS Keychain, Windows Credential Manager, Linux Secret Service keyring://
# Install official provider modules
go get github.com/agentplexus/omnivault-aws
go get github.com/agentplexus/omnivault-keyring
Community Providers

External providers can be developed as separate Go modules and injected via Config.CustomVault:

Category Providers
Password Managers 1Password, Bitwarden, LastPass, KeePass, pass/gopass
Cloud Secret Managers GCP Secret Manager, Azure Key Vault
Enterprise Vaults HashiCorp Vault, CyberArk Conjur, Akeyless, Doppler

Creating Custom Providers

Custom providers can be developed as separate Go modules. Import only the vault package to avoid pulling in unnecessary dependencies:

package myprovider

import (
    "context"
    "github.com/agentplexus/omnivault/vault"
)

type Provider struct {
    // Your provider fields
}

// New creates a new provider instance.
func New(apiKey string) vault.Vault {
    return &Provider{/* ... */}
}

// Implement vault.Vault interface
func (p *Provider) Get(ctx context.Context, path string) (*vault.Secret, error) {
    // Your implementation
}

func (p *Provider) Set(ctx context.Context, path string, secret *vault.Secret) error {
    // Your implementation
}

func (p *Provider) Delete(ctx context.Context, path string) error {
    // Your implementation
}

func (p *Provider) Exists(ctx context.Context, path string) (bool, error) {
    // Your implementation
}

func (p *Provider) List(ctx context.Context, prefix string) ([]string, error) {
    // Your implementation
}

func (p *Provider) Name() string {
    return "myprovider"
}

func (p *Provider) Capabilities() vault.Capabilities {
    return vault.Capabilities{
        Read:  true,
        Write: true,
        // ...
    }
}

func (p *Provider) Close() error {
    return nil
}

Then use it with OmniVault:

import (
    "github.com/agentplexus/omnivault"
    "github.com/yourorg/omnivault-myprovider"
)

client, _ := omnivault.NewClient(omnivault.Config{
    CustomVault: myprovider.New("api-key"),
})

URI Scheme Reference

scheme://path[#field]

# Examples:
env://API_KEY                    # Environment variable
file:///path/to/secret           # File
memory://database/password       # In-memory

# External providers (when installed):
op://vault/item/field            # 1Password
keychain://service/account       # macOS Keychain
aws-sm://secret-name#key         # AWS Secrets Manager
gcp-sm://project/secret          # GCP Secret Manager
azure-kv://vault/secret          # Azure Key Vault
vault://secret/path#field        # HashiCorp Vault

API Reference

Client
// Create a new client
client, err := omnivault.NewClient(omnivault.Config{
    Provider:    omnivault.ProviderEnv,  // Built-in provider
    CustomVault: myVault,                 // OR custom provider
})

// Basic operations
secret, err := client.Get(ctx, "path")
err := client.Set(ctx, "path", &omnivault.Secret{Value: "secret"})
err := client.Delete(ctx, "path")
exists, err := client.Exists(ctx, "path")
paths, err := client.List(ctx, "prefix")

// Convenience methods
value, err := client.GetValue(ctx, "path")      // Returns just the value
value, err := client.GetField(ctx, "path", "field")  // Returns a specific field
err := client.SetValue(ctx, "path", "value")    // Set a simple string value

// Must variants (panic on error)
secret := client.MustGet(ctx, "path")
value := client.MustGetValue(ctx, "path")
Resolver
// Create resolver
resolver := omnivault.NewResolver()

// Register providers
resolver.Register("env", envVault)
resolver.Register("op", onePasswordVault)

// Resolve secrets
value, err := resolver.Resolve(ctx, "env://API_KEY")
secret, err := resolver.ResolveSecret(ctx, "op://vault/item")

// Resolve if it's a secret reference, otherwise return as-is
value, err := resolver.ResolveString(ctx, maybeSecretRef)

// Resolve all values in a map
resolved, err := resolver.ResolveMap(ctx, configMap)
Secret
// Create secrets
secret := &omnivault.Secret{
    Value: "my-secret-value",
    Fields: map[string]string{
        "username": "admin",
        "password": "secret",
    },
    Metadata: omnivault.Metadata{
        Tags: map[string]string{"env": "prod"},
    },
}

// Access values
value := secret.String()           // Primary value
value := secret.GetField("username") // Specific field
bytes := secret.Bytes()            // As bytes

Package Structure

omnivault/
├── vault/              # Core interface (import this for custom providers)
│   ├── interface.go    # Vault interface definition
│   ├── types.go        # Secret, Metadata, SecretRef types
│   └── errors.go       # Standard errors
├── providers/          # Built-in providers
│   ├── env/            # Environment variables
│   ├── file/           # File-based storage
│   └── memory/         # In-memory storage
├── client.go           # Main client
├── resolver.go         # URI-based resolution
├── providers.go        # Provider factory
├── constants.go        # Provider names
├── errors.go           # Client errors
└── types.go            # Type aliases

External Provider Modules

To create an external provider module:

  1. Create a new Go module (e.g., github.com/yourorg/omnivault-onepassword)
  2. Import only github.com/agentplexus/omnivault/vault
  3. Implement the vault.Vault interface
  4. Export a constructor function returning vault.Vault

This architecture ensures:

  • External providers don't bloat the core library with dependencies
  • Providers can be versioned independently
  • Users only install the providers they need

CLI Tool

The omnivault CLI provides secure local secret management with a daemon architecture.

CLI Quick Start
# Start the daemon
omnivault daemon start

# Initialize a new vault with a master password
omnivault init

# Store a secret
omnivault set database/password

# Retrieve a secret
omnivault get database/password

# List all secrets
omnivault list

# Lock the vault
omnivault lock

# Unlock the vault
omnivault unlock

# Check status
omnivault status
CLI Commands
Vault Commands
Command Description
omnivault init Initialize a new vault with a master password
omnivault unlock Unlock the vault with master password
omnivault lock Lock the vault
omnivault status Show vault and daemon status
Secret Commands
Command Description
omnivault get <path> Get a secret value
omnivault set <path> [value] Set a secret (prompts for value if not provided)
omnivault list [prefix] List secrets, optionally filtered by prefix
omnivault delete <path> Delete a secret (with confirmation)
Daemon Commands
Command Description
omnivault daemon start Start the daemon in background
omnivault daemon stop Stop the daemon
omnivault daemon status Show daemon status
omnivault daemon run Run daemon in foreground (for debugging)
Daemon Architecture

The CLI uses a daemon (background service) architecture for secure secret access:

  • Cross-Platform IPC: Unix socket on macOS/Linux, TCP localhost on Windows
  • Session-Based Unlock: Vault stays unlocked until locked or timeout
  • Auto-Lock: Configurable inactivity timeout (default: 15 minutes)
  • Graceful Shutdown: Vault is locked on daemon shutdown
Platform Support
Platform IPC Method Socket/Address
macOS Unix Socket ~/.omnivault/omnivaultd.sock
Linux Unix Socket ~/.omnivault/omnivaultd.sock
Windows TCP 127.0.0.1:19839
Security Model
Encryption
  • Algorithm: AES-256-GCM (authenticated encryption)
  • Key Derivation: Argon2id (memory-hard, resistant to GPU attacks)
    • 3 iterations
    • 64 MB memory
    • 4 parallel threads
  • Salt: Random 32 bytes per vault
  • Nonce: Random 12 bytes per secret
Storage

macOS / Linux:

~/.omnivault/
├── vault.enc           # Encrypted secrets (AES-256-GCM)
├── vault.meta          # Unencrypted metadata (salt, Argon2 params)
├── omnivaultd.sock     # Unix socket (runtime)
└── omnivaultd.pid      # Daemon PID file (runtime)

Windows:

%LOCALAPPDATA%\OmniVault\
├── vault.enc           # Encrypted secrets (AES-256-GCM)
├── vault.meta          # Unencrypted metadata (salt, Argon2 params)
└── omnivaultd.pid      # Daemon PID file (runtime)
Master Password
  • Never stored on disk
  • Used only to derive the encryption key
  • Minimum 8 characters enforced
  • Session-based unlock with configurable timeout

Contributing

Contributions are welcome! Please submit pull requests or create issues for bugs and feature requests.

License

MIT License - see LICENSE for details.

Documentation

Overview

Package omnivault provides a unified interface for secret management across multiple providers including password managers (1Password, Bitwarden), cloud secret managers (AWS, GCP, Azure), and enterprise vaults (HashiCorp Vault).

Basic usage:

client, err := omnivault.NewClient(omnivault.Config{
    Provider: omnivault.ProviderEnv,
})
if err != nil {
    log.Fatal(err)
}
defer client.Close()

secret, err := client.Get(ctx, "API_KEY")

Using a custom provider:

customVault := myprovider.New(...)
client, err := omnivault.NewClient(omnivault.Config{
    CustomVault: customVault,
})

Using the resolver for URI-based secret references:

resolver := omnivault.NewResolver()
resolver.Register("op", onepasswordVault)
resolver.Register("env", envVault)

value, err := resolver.Resolve(ctx, "op://Development/api/token")

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrSecretNotFound       = vault.ErrSecretNotFound
	ErrAccessDenied         = vault.ErrAccessDenied
	ErrInvalidPath          = vault.ErrInvalidPath
	ErrReadOnly             = vault.ErrReadOnly
	ErrNotSupported         = vault.ErrNotSupported
	ErrConnectionFailed     = vault.ErrConnectionFailed
	ErrAuthenticationFailed = vault.ErrAuthenticationFailed
	ErrVersionNotFound      = vault.ErrVersionNotFound
	ErrAlreadyExists        = vault.ErrAlreadyExists
	ErrClosed               = vault.ErrClosed
)

Re-export common errors from the vault package for convenience.

View Source
var (
	// ErrNoProvider is returned when no provider is configured.
	ErrNoProvider = errors.New("no provider configured")

	// ErrUnknownScheme is returned when a secret reference has an unknown scheme.
	ErrUnknownScheme = errors.New("unknown scheme")

	// ErrInvalidSecretRef is returned when a secret reference is malformed.
	ErrInvalidSecretRef = errors.New("invalid secret reference")

	// ErrProviderNotRegistered is returned when a scheme has no registered provider.
	ErrProviderNotRegistered = errors.New("provider not registered for scheme")
)

Client-specific errors.

View Source
var NewTimestamp = vault.NewTimestamp

NewTimestamp creates a Timestamp from a time.Time.

Functions

func IsSecretRef

func IsSecretRef(s string) bool

IsSecretRef checks if a string looks like a secret reference URI.

Types

type BatchVault

type BatchVault = vault.BatchVault

BatchVault provides batch operations for providers that support them.

type Capabilities

type Capabilities = vault.Capabilities

Capabilities indicates what features a provider supports.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client wraps a vault provider with additional functionality.

func NewClient

func NewClient(config Config) (*Client, error)

NewClient creates a new Client with the given configuration.

func (*Client) Capabilities

func (c *Client) Capabilities() vault.Capabilities

Capabilities returns the provider capabilities.

func (*Client) Close

func (c *Client) Close() error

Close releases any resources held by the client.

func (*Client) Delete

func (c *Client) Delete(ctx context.Context, path string) error

Delete removes a secret from the vault.

func (*Client) Exists

func (c *Client) Exists(ctx context.Context, path string) (bool, error)

Exists checks if a secret exists.

func (*Client) Get

func (c *Client) Get(ctx context.Context, path string) (*vault.Secret, error)

Get retrieves a secret from the vault.

func (*Client) GetField

func (c *Client) GetField(ctx context.Context, path, field string) (string, error)

GetField retrieves a specific field from a secret.

func (*Client) GetValue

func (c *Client) GetValue(ctx context.Context, path string) (string, error)

GetValue retrieves only the value of a secret (convenience method).

func (*Client) List

func (c *Client) List(ctx context.Context, prefix string) ([]string, error)

List returns all secrets matching the given prefix.

func (*Client) MustGet

func (c *Client) MustGet(ctx context.Context, path string) *vault.Secret

MustGet retrieves a secret or panics if an error occurs.

func (*Client) MustGetValue

func (c *Client) MustGetValue(ctx context.Context, path string) string

MustGetValue retrieves a secret value or panics if an error occurs.

func (*Client) Name

func (c *Client) Name() string

Name returns the provider name.

func (*Client) Set

func (c *Client) Set(ctx context.Context, path string, secret *vault.Secret) error

Set stores a secret in the vault.

func (*Client) SetValue

func (c *Client) SetValue(ctx context.Context, path, value string) error

SetValue stores a simple string value as a secret (convenience method).

func (*Client) Vault

func (c *Client) Vault() vault.Vault

Vault returns the underlying vault provider. This can be used to access provider-specific functionality.

type Config

type Config struct {
	// Provider is the name of a built-in provider to use.
	// Ignored if CustomVault is set.
	Provider ProviderName

	// CustomVault allows injecting a custom vault implementation.
	// When set, this takes precedence over Provider.
	CustomVault vault.Vault

	// ProviderConfig contains provider-specific configuration.
	// The expected type depends on the provider being used.
	ProviderConfig any

	// HTTPClient is an optional HTTP client for providers that make HTTP requests.
	HTTPClient *http.Client

	// Logger is an optional structured logger.
	Logger *slog.Logger

	// Extra contains additional provider-specific options.
	Extra map[string]any
}

Config holds configuration for creating a new Client.

type EnvConfig

type EnvConfig = env.Config

EnvConfig is an alias for env.Config for convenience.

type ExtendedVault

type ExtendedVault = vault.ExtendedVault

ExtendedVault provides additional features beyond the basic Vault interface.

type FileConfig

type FileConfig = file.Config

FileConfig is an alias for file.Config for convenience.

type Metadata

type Metadata = vault.Metadata

Metadata contains additional information about a secret.

type ProviderName

type ProviderName string

ProviderName represents a known vault provider.

const (
	// OS-Level Credential Stores
	ProviderKeychain  ProviderName = "keychain"  // macOS Keychain
	ProviderWinCred   ProviderName = "wincred"   // Windows Credential Manager
	ProviderLibSecret ProviderName = "libsecret" // Linux Secret Service
	ProviderKeyring   ProviderName = "keyring"   // Cross-platform (auto-detect)

	// Password Managers
	Provider1Password ProviderName = "op"       // 1Password
	ProviderBitwarden ProviderName = "bw"       // Bitwarden
	ProviderLastPass  ProviderName = "lp"       // LastPass
	ProviderKeePass   ProviderName = "kp"       // KeePass/KeePassXC
	ProviderPass      ProviderName = "pass"     // pass/gopass
	ProviderDashlane  ProviderName = "dashlane" // Dashlane

	// Cloud Secret Managers
	ProviderAWSSecretsManager ProviderName = "aws-sm"   // AWS Secrets Manager
	ProviderAWSParameterStore ProviderName = "aws-ssm"  // AWS Systems Manager Parameter Store
	ProviderGCPSecretManager  ProviderName = "gcp-sm"   // Google Cloud Secret Manager
	ProviderAzureKeyVault     ProviderName = "azure-kv" // Azure Key Vault
	ProviderDigitalOcean      ProviderName = "do"       // DigitalOcean
	ProviderIBMSecretsManager ProviderName = "ibm-sm"   // IBM Cloud Secrets Manager
	ProviderOracleVault       ProviderName = "oracle"   // Oracle Cloud Vault

	// Enterprise/Self-Hosted Vaults
	ProviderHashiCorpVault ProviderName = "vault"     // HashiCorp Vault
	ProviderCyberArk       ProviderName = "conjur"    // CyberArk Conjur
	ProviderAkeyless       ProviderName = "akeyless"  // Akeyless
	ProviderInfisical      ProviderName = "infisical" // Infisical
	ProviderDoppler        ProviderName = "doppler"   // Doppler

	// Development/Local
	ProviderEnv    ProviderName = "env"    // Environment variables
	ProviderFile   ProviderName = "file"   // File-based
	ProviderMemory ProviderName = "memory" // In-memory (testing)
	ProviderDotEnv ProviderName = "dotenv" // .env files
	ProviderSOPS   ProviderName = "sops"   // Mozilla SOPS
	ProviderAge    ProviderName = "age"    // age encryption

	// Kubernetes
	ProviderK8sSecrets ProviderName = "k8s" // Kubernetes Secrets
)

Known provider names.

func (ProviderName) Scheme

func (p ProviderName) Scheme() string

Scheme returns the URI scheme for this provider.

func (ProviderName) String

func (p ProviderName) String() string

String returns the string representation of the provider name.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver handles URI-based secret resolution across multiple providers. It routes secret references to the appropriate provider based on the URI scheme.

func NewResolver

func NewResolver() *Resolver

NewResolver creates a new Resolver.

func (*Resolver) Close

func (r *Resolver) Close() error

Close closes all registered providers.

func (*Resolver) Get

func (r *Resolver) Get(scheme string) (vault.Vault, bool)

Get returns the vault provider for the given scheme.

func (*Resolver) MustResolve

func (r *Resolver) MustResolve(ctx context.Context, uri string) string

MustResolve resolves a secret reference or panics if an error occurs.

func (*Resolver) Register

func (r *Resolver) Register(scheme string, v vault.Vault)

Register adds a vault provider for the given scheme. The scheme should match the URI scheme used in secret references (e.g., "op" for op://..., "env" for env://...).

func (*Resolver) Resolve

func (r *Resolver) Resolve(ctx context.Context, uri string) (string, error)

Resolve resolves a secret reference URI and returns the secret value. The URI format is: scheme://path[#field]

Examples:

resolver.Resolve(ctx, "op://vault/item/field")
resolver.Resolve(ctx, "env://API_KEY")
resolver.Resolve(ctx, "aws-sm://my-secret#password")

func (*Resolver) ResolveAll

func (r *Resolver) ResolveAll(ctx context.Context, uris []string) (map[string]string, error)

ResolveAll resolves multiple secret references and returns a map of URI to value. If any resolution fails, it returns an error.

func (*Resolver) ResolveMap

func (r *Resolver) ResolveMap(ctx context.Context, m map[string]string) (map[string]string, error)

ResolveMap resolves all values in a map that are secret references. Non-reference values are passed through unchanged.

func (*Resolver) ResolveSecret

func (r *Resolver) ResolveSecret(ctx context.Context, uri string) (*vault.Secret, error)

ResolveSecret resolves a secret reference URI and returns the full Secret.

func (*Resolver) ResolveString

func (r *Resolver) ResolveString(ctx context.Context, s string) (string, error)

ResolveString resolves a string if it's a secret reference, otherwise returns it as-is. This is useful for processing configuration values that may or may not be secret references.

func (*Resolver) Schemes

func (r *Resolver) Schemes() []string

Schemes returns all registered schemes.

func (*Resolver) Unregister

func (r *Resolver) Unregister(scheme string)

Unregister removes a vault provider for the given scheme.

type Secret

type Secret = vault.Secret

Secret represents a stored secret with its value and metadata.

func NewSecret

func NewSecret(value string) *Secret

NewSecret creates a new Secret with the given value.

func NewSecretBytes

func NewSecretBytes(data []byte) *Secret

NewSecretBytes creates a new Secret with binary data.

func NewSecretWithFields

func NewSecretWithFields(fields map[string]string) *Secret

NewSecretWithFields creates a new Secret with the given fields.

type SecretRef

type SecretRef = vault.SecretRef

SecretRef is a URI-style reference to a secret.

type Timestamp

type Timestamp = vault.Timestamp

Timestamp wraps time.Time to provide custom JSON marshaling.

func Now

func Now() *Timestamp

Now returns a Timestamp for the current time.

type Vault

type Vault = vault.Vault

Vault is the primary interface for secret storage providers.

type VaultError

type VaultError = vault.VaultError

VaultError is a structured error with additional context.

type Version

type Version = vault.Version

Version represents a version of a secret.

Directories

Path Synopsis
cmd
omnivault command
Package main provides the omnivault CLI.
Package main provides the omnivault CLI.
examples
custom_provider command
Example: Custom Provider Implementation
Example: Custom Provider Implementation
internal
client
Package client provides a client for the OmniVault daemon.
Package client provides a client for the OmniVault daemon.
config
Package config provides configuration and path management for OmniVault.
Package config provides configuration and path management for OmniVault.
daemon
Package daemon provides the OmniVault daemon server.
Package daemon provides the OmniVault daemon server.
store
Package store provides encrypted storage for OmniVault.
Package store provides encrypted storage for OmniVault.
providers
env
Package env provides a vault implementation that reads secrets from environment variables.
Package env provides a vault implementation that reads secrets from environment variables.
file
Package file provides a file-based vault implementation.
Package file provides a file-based vault implementation.
memory
Package memory provides an in-memory vault implementation.
Package memory provides an in-memory vault implementation.
Package vault defines the core interfaces for secret storage providers.
Package vault defines the core interfaces for secret storage providers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL