Adding a New Provider
Provider Trait
Section titled “Provider Trait”All providers must implement the Provider trait. Every operation names its
secret with an Address: either the store’s own coordinates (a secret’s
ref) or SecretSpec’s {project}/{profile}/{key} naming convention, which
your provider compiles into its native coordinates via convention_address:
pub trait Provider: Send + Sync { fn name(&self) -> &'static str; fn uri(&self) -> String;
/// Compile SecretSpec's naming convention into the store's native /// coordinates. The single owner of the provider's convention layout. fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress>;
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>>; fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()>;
/// Optional, defaults to empty. The `ref` coordinates your store can /// honor beyond `item`; every other coordinate is rejected for you. fn supported_coords(&self) -> &'static [&'static str] { &[] }
/// Optional, defaults to writable. Read-only providers reject every /// address; providers whose refs name externally managed secrets reject /// native addresses only. State the reason: it is what the user sees. fn check_writable(&self, addr: Address<'_>) -> Result<()> { Ok(()) }
/// Optional batch read. The default resolves each request's address and /// fetches every unique address once, concurrently; override it when the /// store has a real bulk surface (one listing, a batch API). fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> { /* default */ }
/// Optional discovery hook used to build secret declarations from a /// provider. Return definitions only; never put values in descriptions or /// other manifest fields. fn reflect(&self) -> Result<HashMap<String, Secret>> { /* unsupported */ }
/// SecretSpec 0.18+: contextual discovery for hierarchical stores. The /// default calls `reflect()`, preserving context-free implementations. fn reflect_with_context(&self, context: DiscoveryContext<'_>) -> Result<HashMap<String, Secret>> { self.reflect() }}Inside get/set, call self.resolve_coords(addr) to obtain the native
coordinates for any address. It rejects any coordinate outside
supported_coords (e.g. a field on a flat key/value store), so a ref
written for another store fails loudly instead of resolving something else —
you declare the set, you never write the check. Have set call
self.check_writable(addr)? first, so the pre-check and the write agree on
one refusal message.
Convention templates
Section titled “Convention templates”When a provider lets users replace its complete convention layout, call that
option template and support the {project}, {profile}, and {key}
placeholders. Reserve prefix for an option that only prepends literal text to
an otherwise fixed convention. For example, a hierarchical provider might use:
mybackend://account?template=/{profile}/{project}/{key}Render the template only in convention_address, then validate the resulting
native name before any provider I/O. This keeps get, set, batch reads,
generation, and imports in the same address space. Document when a template
omits a placeholder intentionally; in particular, omitting {key} can make
several declarations target the same stored value.
Discovery and init --from
Section titled “Discovery and init --from”The provider trait has a reflect() discovery hook. During secretspec init --from PROVIDER, the CLI constructs the provider, calls the context-aware
reflect_with_context() hook, and turns the returned map into declarations in
a new manifest. That hook is available in SecretSpec 0.18+ and defaults to
reflect(), so context-free implementations keep working. Dotenv and age
implement reflect(); hierarchical stores should implement
reflect_with_context(). A reflected Secret describes the discovered key;
do not copy its value into the description, default, or any other committed
field.
secretspec import PROVIDER is different: it does not call reflect() or
enumerate the source. It iterates the secrets already declared in the active
and default profiles and copies their values into the configured destination.
Implement one of the reflection hooks for manifest discovery, not to change
import semantics. SecretSpec 0.18+ accepts any provider that implements either
hook as an init --from source. Use --project and --profile when the
provider’s convention needs context other than the current directory name and
the default profile.
For a hierarchical store, reflection must have a bounded namespace and a
reversible mapping from native names to SecretSpec keys. A configured template
such as /{profile}/{project}/{key} provides both: render it with
DiscoveryContext, list the prefix before {key}, reject nested or otherwise
ambiguous results, and return the remaining key names. Do not list an entire
account or vault as a fallback.
Both reflection hooks return declarations, not values, so they are also not a runtime namespace-injection API. A Chamber-style “export everything under this path” feature would need a separate value-bearing contract or an intentional extension of the provider trait.
Implementation Steps
Section titled “Implementation Steps”- Create provider module in
src/provider/mybackend.rs - Define config struct with
Serialize,Deserialize,Default, andTryFrom<&Url> - Implement provider struct and use the
register_provider!macro for automatic registration - Implement Provider trait for your provider struct
- Export from mod.rs: Add
pub mod mybackend;
Documentation and Release Visibility
Section titled “Documentation and Release Visibility”The documentation site is built from main, so it can describe code that has
not reached the latest SecretSpec release yet. A new provider must not appear
to be available in the currently released binary before it is published.
Provider page structure
Section titled “Provider page structure”Provider pages should be predictable to scan. Keep the shared sections in the following relative order, inserting provider-specific topics where readers need them:
- A one-sentence description and, for an unreleased provider, the version compatibility notice.
- At a glance: the provider name, URI, read/write behavior, best use case, authentication, optional build feature or availability, and default storage layout.
- Quick start: the shortest useful
set,get, andrunworkflow. Assume the reader completes the following setup section first; keep this example focused on the successful path. - Setup: prerequisites, authentication methods, and required permissions.
- Configuration: URI format, copyable URI examples, and a Project configuration example showing a checked-in alias used by a secret.
- Storage model: the exact provider-native name or path SecretSpec creates, including how projects and profiles stay isolated.
- Use existing secrets: how
refmaps to provider-native coordinates and whether referenced secrets are writable. - CI/CD, when machine authentication or deployment setup differs from local use.
- Advanced configuration for optional provider-specific behavior.
- Troubleshooting and limitations or Security considerations, when there are important operational constraints.
Keep the at-a-glance table compact; explain edge cases in the relevant section instead of expanding the table. Start with this shape:
## At a glance
| | || --- | --- || Provider | `mybackend` || URI | `mybackend://HOST[/path]` || Access | Read and write || Best for | The main workload or audience this provider serves || Authentication | The identity or credential users need || Build feature | `mybackend` || Default storage | `secretspec/{project}/{profile}/{key}` |Use sentence case for section headings. If a standard section does not apply, omit it rather than adding an empty placeholder. Keep command sequences in Quick start and list bare provider specifications in URI examples so the two sections do not repeat one another.
When adding a provider for an upcoming release:
-
Add a version notice near the top of the provider page:
:::note[Version compatibility]The MyBackend provider is added in SecretSpec 0.16.::: -
Mark the provider as
(0.16+)anywhere it appears in a provider list, table, selector example, sidebar, landing page, README, or generated documentation description. -
If the provider changes authentication or configuration syntax, label the new form explicitly with its target version.
-
Add the provider under the existing
Unreleasedsection inCHANGELOG.md.
Update every provider location; names otherwise drift out of sync:
docs/src/content/docs/providers/<provider>.mddocs/astro.config.ts— sidebar andstarlightLlmsTxtprovider summarydocs/src/content/docs/concepts/providers.md— available providers tabledocs/src/content/docs/reference/providers.md— provider details and security considerationsdocs/src/pages/index.astro—providerMetadataand any provider selector examplesdocs/src/content/docs/quick-start.mdx— provider selector exampleREADME.md— provider lists and provider selector example
Use durable wording such as “Added in SecretSpec 0.16.” The (0.16+) labels
may remain where knowing the minimum version is useful.
Apply the same rule to unreleased CLI commands and configuration fields: place a version notice beside the command or field, not only on a separate concept page. Readers often arrive directly from search results.
Example Implementation
Section titled “Example Implementation”use super::Provider;use crate::{Result, SecretSpecError};use url::Url;use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct MyBackendConfig { pub endpoint: Option<String>,}
impl Default for MyBackendConfig { fn default() -> Self { Self { endpoint: None } }}
impl TryFrom<&Url> for MyBackendConfig { type Error = SecretSpecError;
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> { if url.scheme() != "mybackend" { return Err(SecretSpecError::ProviderOperationFailed( format!("Invalid scheme '{}' for mybackend provider", url.scheme()) )); }
// Parse URL into configuration Ok(Self { endpoint: url.host_str().map(|s| s.to_string()), }) }}
pub struct MyBackendProvider { config: MyBackendConfig,}
crate::register_provider! { struct: MyBackendProvider, config: MyBackendConfig, name: "mybackend", description: "My custom backend provider", schemes: ["mybackend"], examples: ["mybackend://api.example.com", "mybackend://localhost:8080"],}
impl MyBackendProvider { pub fn new(config: MyBackendConfig) -> Self { Self { config } }}
impl Provider for MyBackendProvider { fn name(&self) -> &'static str { Self::PROVIDER_NAME }
fn uri(&self) -> String { "mybackend".to_string() }
fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress> { Ok(NativeAddress { item: format!("secretspec/{}/{}/{}", project, profile, key), ..Default::default() }) }
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> { let coords = self.resolve_coords(addr)?; // Reject coordinates the store cannot honor, then read coords.item Ok(None) }
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { let coords = self.resolve_coords(addr)?; // Write value at coords.item Ok(()) }}