Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ jsonwebtoken = "9"
pulldown-cmark = "0.13.0"
hickory-resolver = "0.24.4"
socket2 = "0.6"
zeroize = "1.8.2"

[build-dependencies]
# Doc comment scanner (build.rs extracts /// @ntnt blocks)
Expand Down
82 changes: 81 additions & 1 deletion docs/STDLIB_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- [std/net](#stdnet)
- [std/path](#stdpath)
- [std/postgres](#stdpostgres)
- [std/secrets](#stdsecrets)
- [std/sqlite](#stdsqlite)
- [std/string](#stdstring)
- [std/time](#stdtime)
Expand Down Expand Up @@ -6732,7 +6733,7 @@ fetch(url_or_options: String | Map, options?: Map) -> Result<Response, String>

Make an HTTP request to a URL.

Accepts one or two arguments: - One argument: a URL string for a simple GET request, or an options map with full control over method, headers, body, authentication, cookies, and timeout. - Two arguments: a URL string and an options map. The URL is merged into the options map automatically. Options map keys: url (set automatically in 2-arg form), method, headers, body, json, form, auth, cookies, timeout.
Accepts one or two arguments: - One argument: a URL string for a simple GET request, or an options map with full control over method, headers, body, authentication, cookies, and timeout. - Two arguments: a URL string and an options map. The URL is merged into the options map automatically. Options map keys: url (set automatically in 2-arg form), method, headers, body, json, form, auth, cookies, timeout. Opaque Secret values are accepted only in header values, cookie values, basic-auth fields, raw bodies, JSON leaves, and form values. Requests containing a Secret do not follow redirects, preventing credentials or 307/308 bodies from crossing origins.

**Parameters:**

Expand Down Expand Up @@ -10880,6 +10881,85 @@ rollback(db) // => true // Roll back an active transaction

---

## std/secrets

Provider-neutral secret lookup with opaque, redacted values

```ntnt
import { get_secret, require_secret } from "std/secrets"
```

### Functions

| Function | Description |
|----------|-------------|
| [`get_secret`](#getsecret) | Looks up a secret by its provider-neutral logical name. |
| [`require_secret`](#requiresecret) | Looks up a required secret and fails closed when it is not configured. |

#### `get_secret`

```ntnt
get_secret(name: String) -> Option<Secret>
```

Looks up a secret by its provider-neutral logical name.

The v0.5.1 development-only environment provider reads the exact environment variable name and is disabled when `NTNT_ENV` is `production` or `prod`. Projects with an `ntnt.toml` must declare accessible names under `[secrets.<NAME>]`; undeclared lookups fail before contacting the provider. Declaration metadata may contain `label`, `description`, `required`, and `environments`; secret values are never accepted in the manifest. Secret values remain opaque and redact themselves in output and diagnostics.

**Parameters:**

- `name` — A validated logical secret name

**Returns:** Some(Secret) when configured, otherwise None

**Examples:**

```ntnt
get_secret("STRIPE_SECRET_KEY") // => Some([REDACTED]) // Optional lookup
```

**Errors:**

- **RuntimeError**: Unsupported secrets provider — *Fix: Set NTNT_SECRETS_PROVIDER=env for v0.5.1*

**See also:** `require_secret`

*Since v0.5.1*

---

#### `require_secret`

```ntnt
require_secret(name: String) -> Secret
```

Looks up a required secret and fails closed when it is not configured.

Use this at startup or immediately before an approved secret-consuming sink. In v0.5.1, `std/http.fetch` accepts Secret values as header, cookie, basic-auth, raw body, JSON-leaf, and form values. Templates, public JSON, URL/CSV/string conversion, KV storage, and job payloads reject secrets. There is intentionally no general Secret-to-String reveal function. The error identifies only the logical name and never includes the value.

**Parameters:**

- `name` — A validated logical secret name

**Returns:** The opaque Secret value

**Examples:**

```ntnt
require_secret("STRIPE_SECRET_KEY") // => [REDACTED] // Required lookup
```

**Errors:**

- **RuntimeError**: Required secret is not configured — *Fix: Configure the named secret in the selected provider*

**See also:** `get_secret`

*Since v0.5.1*

---

## std/sqlite

SQLite database operations
Expand Down
114 changes: 114 additions & 0 deletions src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::ast::*;
use crate::config::{get_type_mode, type_warn_dedup, TypeMode};
use crate::contracts::{ContractChecker, OldValues, StoredValue};
use crate::error::{IntentError, Result, TypeContext};
pub use crate::secret::SecretValue;
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt;
Expand Down Expand Up @@ -57,6 +58,9 @@ pub enum Value {
/// String value
String(String),

/// Opaque secret value. Ordinary formatting is always redacted.
Secret(SecretValue),

/// Array value
Array(Vec<Value>),

Expand Down Expand Up @@ -205,6 +209,7 @@ impl Value {
Value::Float(_) => true,
// Empty collections are falsy
Value::String(s) => !s.is_empty(),
Value::Secret(_) => true,
Value::Array(a) => !a.is_empty(),
Value::Map(m) => !m.is_empty(),
// None is falsy, Some(x) is truthy
Expand All @@ -223,6 +228,7 @@ impl Value {
Value::Float(_) => "Float",
Value::Bool(_) => "Bool",
Value::String(_) => "String",
Value::Secret(_) => "Secret",
Value::Array(_) => "Array",
Value::Map(_) => "Map",
Value::Range { .. } => "Range",
Expand All @@ -240,6 +246,19 @@ impl Value {
Value::Continue => "Continue",
}
}

/// Return true when this value directly or recursively contains a secret.
pub fn contains_secret(&self) -> bool {
match self {
Value::Secret(_) => true,
Value::Array(values) => values.iter().any(Value::contains_secret),
Value::Map(values) => values.values().any(Value::contains_secret),
Value::Struct { fields, .. } => fields.values().any(Value::contains_secret),
Value::EnumValue { values, .. } => values.iter().any(Value::contains_secret),
Value::Return(value) => value.contains_secret(),
_ => false,
}
}
}

impl fmt::Display for Value {
Expand All @@ -250,6 +269,7 @@ impl fmt::Display for Value {
Value::Float(n) => write!(f, "{}", n),
Value::Bool(b) => write!(f, "{}", b),
Value::String(s) => write!(f, "{}", s),
Value::Secret(secret) => write!(f, "{}", secret),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
write!(f, "[{}]", items.join(", "))
Expand Down Expand Up @@ -1749,6 +1769,8 @@ impl Interpreter {
/// Set the main source file for hot-reload tracking
pub fn set_main_source_file(&mut self, path: &str) {
self.main_source_file = Some(path.to_string());
#[cfg(not(test))]
crate::stdlib::secrets::configure_for_source(path);
// Store the current mtime
self.main_source_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
}
Expand Down Expand Up @@ -8435,6 +8457,16 @@ impl Interpreter {
result: &mut String,
) -> Result<bool> {
match self.eval_expression(condition) {
Ok(v) if v.contains_secret() => {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be used in template conditions".to_string(),
),
context,
result,
)?;
Ok(false)
}
Ok(v) => Ok(v.is_truthy()),
Err(IntentError::UndefinedVariable { .. }) => Ok(false),
Err(e) => {
Expand All @@ -8458,6 +8490,16 @@ impl Interpreter {
// forgiving → render empty string silently
match self.eval_expression(expr) {
Ok(v) => {
if v.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be rendered in templates".to_string(),
),
"expression",
&mut result,
)?;
continue 'parts;
}
let s = v.to_string();
result.push_str(&html_escape_string(&s));
}
Expand All @@ -8470,6 +8512,16 @@ impl Interpreter {
// Error boundary: behaviour depends on NTNT_TYPE_MODE.
match self.eval_expression(expr) {
Ok(v) => {
if v.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be rendered in templates".to_string(),
),
"raw expression",
&mut result,
)?;
continue 'parts;
}
result.push_str(&v.to_string());
}
// Undefined template variables render as empty string
Expand Down Expand Up @@ -8499,6 +8551,17 @@ impl Interpreter {
}
}
};
if value.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be passed through template filters"
.to_string(),
),
"filtered expression",
&mut result,
)?;
continue 'parts;
}
let mut skip_escape = false;
for filter in filters {
if filter.name == "safe" || filter.name == "raw" {
Expand All @@ -8512,6 +8575,18 @@ impl Interpreter {
}
}
}
// Filter arguments can introduce a Secret (for example,
// `default(secret)`), so re-check the result before rendering.
if value.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be rendered in templates".to_string(),
),
"filtered expression result",
&mut result,
)?;
continue 'parts;
}
let s = value.to_string();
if skip_escape {
result.push_str(&s);
Expand Down Expand Up @@ -8544,6 +8619,17 @@ impl Interpreter {
}
}
};
if value.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be passed through template filters"
.to_string(),
),
"raw filtered expression",
&mut result,
)?;
continue 'parts;
}
for filter in filters {
match self.apply_template_filter(&value, filter) {
Ok(v) => value = v,
Expand All @@ -8553,6 +8639,18 @@ impl Interpreter {
}
}
}
// Filter arguments can introduce a Secret (for example,
// `default(secret)`), so re-check the result before rendering.
if value.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be rendered in templates".to_string(),
),
"raw filtered expression result",
&mut result,
)?;
continue 'parts;
}
result.push_str(&value.to_string());
}
TemplatePart::ForLoop {
Expand All @@ -8574,6 +8672,16 @@ impl Interpreter {
continue;
}
};
if iterable_value.contains_secret() {
Self::handle_template_error(
IntentError::type_error(
"Secret values cannot be iterated in templates".to_string(),
),
"for-loop iterable",
&mut result,
)?;
continue 'parts;
}

match iterable_value {
Value::Array(ref items) if items.is_empty() => {
Expand Down Expand Up @@ -10959,6 +11067,12 @@ impl Interpreter {
}

fn eval_binary_op(&self, op: BinaryOp, lhs: Value, rhs: Value) -> Result<Value> {
if lhs.contains_secret() || rhs.contains_secret() {
return Err(IntentError::type_error(
"Secret values cannot be used with binary operators".to_string(),
));
}

// Handle EnumValue and handle type equality
if matches!(op, BinaryOp::Eq | BinaryOp::Ne) {
let lhs_is_enum = matches!(&lhs, Value::EnumValue { .. });
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub mod intent_studio_server;
pub mod interpreter;
pub mod lexer;
pub mod parser;
mod secret;
#[cfg(test)]
mod std_secrets_tests;
pub mod stdlib;
pub mod typechecker;
pub mod types;
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,7 @@ fn print_repl_help() {
" {} get_env, set_env, load_env, args, cwd",
"std/env".cyan()
);
println!(" {} get_secret, require_secret", "std/secrets".cyan());
println!(
" {} join, dirname, basename, extname",
"std/path".cyan()
Expand Down
Loading
Loading