@ontemper/resources
v0.5.0
Published
Reads Temper variables and resource records from workflow code.
Readme
@ontemper/resources
Reads variables and resource records from Temper workflow code, plus the legacy partners and connections surface. Protocol clients (SFTP, PostgreSQL, Kafka, S3, Azure Blob, HTTP) are not part of this package: resolve a record, then pass it to the client you install.
Installation
npm install @ontemper/resourcesUsage
import { createServer } from 'unnbound-events';
import { env, selectPartnerFromEvent } from '@ontemper/resources';
const server = createServer();
// Determine partner from URL parameter
server.get(
'/webhook/:partnerId',
selectPartnerFromEvent((c) => c.request.params?.partnerId),
(c) => ({ status: 200, body: {} }),
);
// Determine partner from authorization header
server.get(
'/webhook',
selectPartnerFromEvent((c) => {
const apiKey = c.request.headers.authorization;
if (!apiKey) return;
return env.partners.search((partner) => partner.variables.tryGet('UNNBOUND_API_KEY') === apiKey);
}),
async (c) => {
// Get current partner (set by selectPartnerFromEvent middleware)
const partner = env.partners.current;
// Get a specific partner by key
const specificPartner = env.partners.get('EXTERNAL_PARTNER_ID');
// Get a global connection
const globalConnection = env.connections.get('TURVO_OAUTH');
// Get a partner connection
const partnerConnection = partner.connections.get('TURVO_OAUTH');
// Get a global variable (live through the runtime resolver when the workflow carries a resolver token)
const globalApiKey = await env.variables.get('GITHUB_API_KEY');
// Get a partner variable
const partnerApiKey = partner.variables.get('GITHUB_API_KEY');
return { status: 200, body: {} };
},
);API Reference
env
The main environment accessor singleton.
env.initialize()
Loads the initial environment configuration before a workflow handles requests, then refreshes it on a background interval. It retries transient startup failures before exiting when configuration cannot be loaded.
env.reload()
Refreshes the environment configuration. Before the first successful load,
reload() waits for configuration so the workflow has the state it needs. After
that load, reload() returns immediately while the cached configuration is
fresh; once it expires, reload() waits for the refresh and rejects if it fails.
Scheduled background refreshes keep the last known good configuration and log a
warning.
Configuration refreshes every five minutes by default. Set
UNNBOUND_ENV_CACHE_MAX_AGE to a positive duration in milliseconds to change
the interval. Set UNNBOUND_ENV_API_TIMEOUT to change the timeout, in
milliseconds, for the configuration API and for every runtime resolver call
(env.variables.get, env.resources.*). The timeout defaults to 5 seconds.
The SDK selects the Configuration for the current environment from TEMPER_ENVIRONMENT
(sandbox, staging, or production). Temper injects it in every workflow
environment; nothing to configure. If neither the Temper name nor the legacy
UNNBOUND_ENVIRONMENT name is set, initialization makes no request, retries
path resolution three times, logs the final error, and exits the process.
env.partners
env.partners.current- Get the current partner (requiresselectPartnerFromEventmiddleware)env.partners.get(key)- Get a partner by keyenv.partners.getAll()- Get all partnersenv.partners.has(key)- Check if a partner existsenv.partners.search(predicate)- Search for a partner by predicate
env.connections
env.connections.get(key, type?)- Get a global connection by keyenv.connections.has(key)- Check if a connection exists
env.variables
await env.variables.get(key)- Read a variable. With a runtime-resolver token (UNNBOUND_RUNTIME_TOKENorUNNBOUND_RUNTIME_TOKEN_FILE) the value is read live from the API and cached for 30 seconds; without one it comes from the boot-time configuration snapshot. A missing variable rejects with aServerError.env.variables.has(key)- Whether the boot-time snapshot holds the variable (synchronous).
Logs mask the secret values read through the runtime resolver: the values the API flags as secret are registered as
redaction masks for the current trace. When a trace ends, its masks are kept process-wide so a value returned from it
stays masked in later logs. Every resolver read registers the secrets it returns, cache hits included, so a trace
that reads a cached secret masks it too. Treat masking as a safeguard, not a guarantee: do not log secrets.
Boot-time snapshot values (the no-token path of env.variables, and env.partners) carry no secret flag and are not
masked; do not log them.
env.resources
Resources are typed records (an SFTP server, a PostgreSQL database, an HTTP API, ...) stored on the platform and read
live through the runtime resolver. inspect and resolve take the resource key; select takes
{ resourceTypeKey, match }. Every method accepts an optional { cache?: boolean; ttlMs?: number } as its last
argument. A resolver failure rejects with a ServerError whose cause names the target and the API error. When no
resource answers an inspect (a key outside the workflow's reach) or a select (no match), the error is a
ResourceNotFoundError: a ServerError with status 404. Map it to your route's status without walking the cause
chain; left unhandled, it answers 404. A resolve miss stays a ServerError with status 500: the API answers a
missing record and a broken reference inside a found record with the same 404, and a broken reference is a
configuration fault. Its cause still carries the 404. A missing Variable also stays a ServerError with status 500.
import { env, HttpApiResource, ResourceNotFoundError } from '@ontemper/resources';
import { ServerError } from 'unnbound-events';
import { registerRedactionMasks } from 'unnbound-logger-sdk';
// Mask the presented key first: a failed lookup leaves the request, key included, in the request's logs.
registerRedactionMasks([tenantKey]);
const { key } = await env.resources
.select({ resourceTypeKey: 'http_api', match: { tenantKey } })
.catch((error: unknown) => {
if (error instanceof ResourceNotFoundError) throw new ServerError({ status: 401, message: 'Unauthorized.' });
throw error;
});
const api = await env.resources.resolve(key, HttpApiResource);An ordinary resolver read writes no log line or span to the workflow's logs. During a resolver outage, a read that serves a stale value (see below) logs one warning per key per outage and resolves; a read that has no stale value to serve rejects. The API records each uncached read in the Resources usage history.
await env.resources.inspect(key)- The record without its secret fields:resourceTypeKey, scope, literal values.await env.resources.resolve(key, schema?)- The value with every secret field filled in. With a Zodschema, the value is parsed by it: defaults are applied, and a mismatch rejects with an error that names the field. The exported schemas' messages never include the value; a custom schema's own refinement messages are forwarded as written, so do not interpolate the value into them. The secret values are registered as redaction masks, as for every resolver read.await env.resources.select({ resourceTypeKey, match })-{ key }of the record whose named selectable field matches (for example thetenantKeyanhttp_apitenant presents at ingress). A type may mark several fields selectable;matchnames exactly one. Cached per type and match. A miss is cached for 10 seconds, so a burst of requests with one unknown key costs one resolver call; a record created meanwhile is found once that time passes, or at once by acache: falseselect. Once 60 distinct matches got a 404 this minute, those 404s and each later one stay cached until the minute ends, unless the bounded miss cache (10,000 entries) evicts them first; a match the API has not answered is always sent (see Tuning).
The package exports one Zod schema, and its type, for each typed built-in resource:
SftpResource, HttpApiResource, PostgresqlResource, KafkaResource, S3Resource,
AzureBlobResource, As2IdentityResource, and As2PartnerResource. These readers take the resolved record value,
not a transport envelope. The partner's identity reference arrives inline. generic and custom resource types
resolve as a JsonObject; pass your own Zod schema to parse them.
All readers follow the platform's lenient-read contract:
- Unknown fields pass through unchanged.
- In
HttpApiResource, unknown authentication variants parse as{ type: 'unsupported', variant, fields }. Refuse that result in code that does not know the new variant. The PostgreSQL, SFTP, Kafka, S3, and Azure Blobauthdiscriminators are closed: an unknown variant fails the read. - Closed values that choose transport security, credential placement, or AS2 cryptography reject with
UnknownRecordValueError. The error identifies the record and field path, never the value. - Readers apply catalog defaults, including PostgreSQL
sslmode: 'verify-full', HTTP API headers and authentication, S3 region and path style, Azure endpoint suffix, OAuth token request format, API-key placement and name, and AS2 partner security settings.
Resolve a record and pass it to the protocol client:
import { env, SftpResource } from '@ontemper/resources';
import { TemperSftpClient } from '@ontemper/sftp';
const record = await env.resources.resolve('partner-sftp', SftpResource);
const sftp = new TemperSftpClient(record);
await sftp.connect();TemperSftpClient maps revision-2 auth variants (password, private_key, and
private_key_and_password) to ssh2. The two-factor variant uses ssh2's authentication handler to send the password
after public-key authentication. It also maps algorithms and continues to verify hostKeyFingerprints.
PostgreSQL Resources use the dedicated @ontemper/postgresql SDK, which owns TLS, pool limits, and private builder transport:
import { createPostgresqlPool } from '@ontemper/postgresql';
const pool = await createPostgresqlPool('orders-db');getDatabaseTunnelAuthorization(key) is the transport adapter's authorization reader.
It returns { url, token } using the current runtime token provider. Call it on each
physical connection; do not cache, log, or persist its result. It does not open a
connection or authorize a destination locally. The API performs those access checks.
Use kafkaConfig to build a KafkaJS configuration:
import { env, kafkaConfig, KafkaResource } from '@ontemper/resources';
import { Kafka } from 'kafkajs';
const resource = await env.resources.resolve('events', KafkaResource);
const kafka = new Kafka(kafkaConfig(resource));The mapper derives TLS from tls, sets clientId when the record has one, maps PLAIN and SCRAM SASL, caches OAuth
client-credentials tokens, and supports MSK IAM through the optional aws-msk-iam-sasl-signer-js peer dependency.
Install that peer only when a record uses aws_msk_iam. When awsAuth.type is assume_role, also install the
optional @aws-sdk/credential-providers peer dependency. sasl_plain, oauthbearer, and aws_msk_iam records
require tls. The return type, KafkaClientConfig, is assignable to KafkaJS KafkaConfig and does not import
kafkajs.
S3, Azure Blob, and HTTP API records carry explicit auth variants. Map the resolved fields to the client library you
use; do not restore legacy flat credential fields or infer an ambient identity from missing credentials.
The env.resources.http(), sftp(), postgresql(), kafka(), and storage() client factories and the
createHttpClient connection client are removed. Use the PostgreSQL SDK for database Resources; resolve records for other installed protocol clients.
Set UNNBOUND_RUNTIME_RESOLVE_CACHE_MAX_AGE (milliseconds) to change the 30 second resolver cache. The cache holds at
most 10,000 values and drops the least recently used first. Each read is one entry, so one tenant costs a select, a
resolve, and its Variables: 1,000 entries would hold only about 450 tenants per pod and evict live tenants into a
herd of re-fetches. A record is a few hundred bytes to a few KB, so 10,000 entries stay well under 50 MB. Misses live
in their own cache and never evict values.
When the API is unreachable, times out, answers with a 5xx status, sends a response cut short, or no runtime token is
available, a read serves the last good value past its TTL: up to 15 minutes for resolve, inspect, and
env.variables.get, and up to 5 minutes for select. This applies to env.variables.get only in a workflow
configured with a runtime token; without one, it reads the boot-time snapshot and never calls the API. The select
window is shorter because a selection often authorizes a tenant: a tenant key revoked just before an outage keeps
matching for at most the TTL plus 5 minutes.
The legacy env.partners snapshot already keeps a revoked key for up to 5 minutes without any outage. The first
stale read of a key logs one warning; a successful refresh resets it, so a later outage logs again. During the
outage the SDK retries a key only when a read of it arrives at least 5 seconds after the last failed attempt, so a
recovered API is seen on the first such read. Set TEMPER_RUNTIME_RESOLVE_STALE_IF_ERROR_MAX_AGE and
TEMPER_RUNTIME_RESOLVE_SELECT_STALE_IF_ERROR_MAX_AGE (milliseconds) to change the windows; 0 turns stale serving
off. A stale value can be an old credential: a value rotated or revoked during an outage stays in use until a retry of
that key reaches the API or the window ends. A retry updates only the value the SDK returns: a client the workflow
built from an earlier read keeps that credential until the workflow reads the resource again and rebuilds or updates
the client.
A 401, 403, 404, or 409 answer never serves a stale value and drops the cached one, including when a cache: false
read receives it. cache: false reads skip both caches and never serve a stale value. Any API answer to a
cache: false read (a value or a refusal) clears what older reads cached for that key, and a refresh that started
before it does not write its older answer back; a read that started after it keeps its answer, even when the bypass
answers later. The bypass answer itself is not cached: the next ordinary read calls the API again,
and during an outage it has no stale value to serve. Stale serving applies only to a value the SDK still holds: past
10,000 values, the least recently used is evicted, and an outage read of an evicted key rejects.
The runtime token is sent as long as it is valid, even when the token provider has not refreshed it. An expired token waits at most 2 seconds for a fresh one, then the read fails as an outage.
Tuning
Each setting applies per workflow process (one pod). An environment variable takes a non-negative integer, in milliseconds for durations; an invalid value falls back to the default.
| Setting | Default | Environment variable | Protects against | Costs |
| ----------------------- | -------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Value TTL | 30 s | UNNBOUND_RUNTIME_RESOLVE_CACHE_MAX_AGE (min 1) | One resolver call per read | An edit reaches the workflow up to one TTL late |
| Stale window (reads) | 15 min | TEMPER_RUNTIME_RESOLVE_STALE_IF_ERROR_MAX_AGE (0 off) | An API outage failing resolve, inspect, and env.variables.get | A credential rotated or revoked during an outage stays in use for up to the window |
| Stale window (select) | 5 min | TEMPER_RUNTIME_RESOLVE_SELECT_STALE_IF_ERROR_MAX_AGE (0 off) | An API outage failing every tenant lookup | A tenant key revoked during an outage keeps matching for up to the window |
| Outage retry interval | 5 s | none | One request timeout per read during an outage | A recovered API is seen up to 5 s late |
| Cache size | 10,000 entries | TEMPER_RUNTIME_RESOLVE_CACHE_MAX_ENTRIES (min 1) | Unbounded memory, and live tenants evicted into a re-fetch herd; when full, it evicts an expired entry first, then the least recently used fresh one, and a stale-window entry (the only copy an outage can serve) last | Memory, well under 50 MB at a few KB per record |
| Miss TTL (select) | 10 s | TEMPER_RUNTIME_RESOLVE_NOT_FOUND_MAX_AGE (0 off) | Retry bursts with one wrong key: each distinct wrong key costs one API call per 10 s | A tenant created after a miss is found up to 10 s later (at once with cache: false) |
| Miss cap (select) | 60 per minute | TEMPER_RUNTIME_RESOLVE_MISS_CAP_PER_MINUTE (0 off) | A caller replaying unknown keys: once 60 distinct keys got a 404 this minute, those 404s and each later one stay cached until the minute ends, so a refused key is not sent again while the bounded miss cache keeps it; one warning per minute | A key the API refused during that minute, such as a tenant created just after its 404, is found when the minute ends (at once with cache: false). A key the API has not answered is never refused, so distinct unknown keys still cost one call each |
| Expired-token wait | 2 s | none | Every read stalling while the token provider cannot refresh | A read with an expired token fails as an outage after 2 s |
| Fresh-token wait | 35 s | none | A failed read at pod start or after a 401 while the provider publishes a token | Such a read can wait up to 35 s |
| Request timeout | 5 s | UNNBOUND_ENV_API_TIMEOUT | A hung resolver call | A slower answer fails, or is served stale |
| Expired-entry sweep | 60 s | none | Expired entries holding memory | One scan of the cache per minute |
Partner Accessors
Each partner has its own accessor for connections and variables:
const partner = env.partners.get('PARTNER_KEY');
// Partner connections
partner.connections.get('CONNECTION_KEY');
// Partner variables
partner.variables.get('VARIABLE_KEY');
// Partner attributes
partner.attributes;selectPartnerFromEvent
Middleware that sets the current partner from the request context.
selectPartnerFromEvent((c) => {
// Return partner key from request context
return c.request.params?.partnerId;
});selectPartner
Utility to execute code in a partner context.
import { selectPartner } from '@ontemper/resources';
await selectPartner('PARTNER_KEY', async (partner) => {
// Code runs with partner context
const apiKey = partner.variables.get('API_KEY');
});Connection Types
// Basic Auth
{
type: 'basic-auth',
config: {
url: string,
headers: { key: string, value: string }[],
username: string,
password: string,
}
}
// Bearer Token
{
type: 'bearer-token',
config: {
url: string,
headers: { key: string, value: string }[],
apiKey: string,
apiKeyPlacement: 'authorization_bearer' | 'custom_header',
apiKeyHeaderName?: string, // required when placement is 'custom_header'
}
}
// OAuth 2.0
{
type: 'oauth20',
config: {
url: string, // token endpoint
headers: { key: string, value: string }[],
grantType?: 'client_credentials' | 'password', // defaults to 'client_credentials'
bodyParams?: { key: string, value: string }[], // extra params merged into token request body
clientId: string,
clientSecret: string,
username?: string, // required for password flow
password?: string, // required for password flow
}
}
// Request (custom headers, no auth)
{
type: 'request',
config: {
url: string,
headers: { key: string, value: string }[],
}
}Managed PostgreSQL limits and timeout monitoring
PostgreSQL pools open connections on demand, use at most five connections, and
set a ten-second statement timeout. Managed placements use verified TLS and
enforce the Resource grant in PostgreSQL. Revoking a grant ends active sessions.
Statement timeouts emit a temper.database.query.timeout warning without SQL,
parameters, or credentials. The query still returns its original PostgreSQL
error, including queries on a client checked out with pool.connect().
