Status: draft, no implementation yet. This document is the input for writing tests; no production code should be written until the scenarios in §8 Test Scenarios are agreed.
In scope (v1):
/etc/infisical, with strict permission checks.Out of scope (v1) — explicitly deferred:
get-secret! call performs its own
login). Caching is a pure optimization with a real correctness risk
(expiry, thread-safety) — not justified until there's a measured need.clj-http-lite's
problem, not this library's (see §2).clj-http-lite — all HTTP.org.clojure/data.json — all JSON encode/decode.clojure.java.io preferred everywhere else.java.nio.file interop
(Files/getPosixFilePermissions, Files/getOwner). This is the one place
we reach past core Clojure, and only from the single action responsible for
stat'ing /etc/infisical.No other runtime dependency should be needed for v1.
Retry: confirmed — network-failure retries are handled by clj-http-lite
itself (its own timeout/connection-failure behavior), not reimplemented here.
Note for implementation: as of the current clj-commons/clj-http-lite
user guide, the library exposes :conn-timeout/:socket-timeout options but
has no built-in retry-on-failure handler — "use clj-http-lite's own retry
logic" in practice means this library adds none, a single request attempt
per call, and a connection failure simply propagates as an uncaught
java.io.IOException-family exception rather than being caught and wrapped.
If retries turn out to be wanted later, that's a caller-side concern (wrap
get-secret! in retry logic) rather than something clj-infisical should
own — revisit if that assumption is wrong.
POST {site-url}/api/v1/auth/universal-auth/login
Content-Type: application/json
{"clientId": "<client-id>", "clientSecret": "<client-secret>"}
Response 200:
{
"accessToken": "...",
"expiresIn": 7200,
"accessTokenMaxTTL": 7200,
"tokenType": "Bearer"
}
Non-200 (e.g. 400/401) → authentication failure. Body is JSON with an
error message; treat any non-2xx as :clj-infisical/auth-failed (§7).
GET {site-url}/api/v3/secrets/raw/{secret-name}?workspaceId=...&secretPath=...&environment=...
Authorization: Bearer <access-token>
workspaceId — required on the wire. Public API uses :project-id,
not :workspace-id (revised from the earlier draft — see §9): Infisical's
own API surface is inconsistent about this across versions — the v3
endpoint used here says workspaceId, while the newer v4 endpoint
(GET /api/v4/secrets/{name}) says projectId, matching current
UI/CLI terminology. secret-request (§5.5) is the one place that
translates the public :project-id into the workspaceId query
param this endpoint actually expects — isolating that translation
there means a future migration to v4 only touches secret-request,
not every caller of this library.environment — required by this library (defaulted, see §5.6) so callers
always know which environment they read.secretPath — optional, defaults to /.viewSecretValue param on this endpoint — the raw path already
returns the plaintext value directly.Response 200:
{
"secret": {
"secretValue": "..."
}
}
The raw endpoint's full response may carry more fields (secretKey,
version, etc., by analogy with other Infisical secret endpoints) beyond
the confirmed secret.secretValue. Revised from the earlier draft:
rather than discarding those extra fields, parse-secret-response (§5.5)
passes the whole secret object through, keywordizing keys, with only
secretValue/:secret-value treated as guaranteed-present. This is what
makes the "raw" half of §5.6 possible.
404 → secret not found → :clj-infisical/secret-not-found.
Other non-2xx → :clj-infisical/http-error. For any non-2xx, the response
body is also JSON-decoded when possible and kept on the thrown error (§7) —
Infisical's own error bodies typically carry a message/error field, and
this library shouldn't force callers to re-parse the raw body string to get
at that.
Per the ACD model, every var in the library is one of:
! (login!, fetch-secret!, read-file!,
stat!). Result depends on I/O, the network, or the clock. Not safe to
call twice and expect the same result, or to call from a map.!. Pure function of its arguments.
Same input → same output, forever, no side effects. Safe to unit-test with
plain data, no mocking.Every action in this library is a thin wrapper: gather raw I/O results, hand them to a calculation, return what the calculation decided. No action should contain a business decision (a conditional that changes program behavior based on content, not just on success/failure of I/O).
clj-infisical.data — DataPlain maps, no defrecord (keeps callers free to use normal map functions). Documented here as shapes, not code.
| Name | Shape |
|---|---|
Credentials | {:client-id string, :client-secret string, :source #{:env :file}} |
Config | {:project-id string, :environment string, :secret-path string, :site-url string} |
AccessToken | {:token string, :expires-in int, :token-type string} |
Secret | The response's secret object, keywordized, passed through as-is. :secret-value is the only key this library guarantees (throws :invalid-response if absent); every other key Infisical happens to include (:secret-key, :version, ...) rides along unexamined. |
ErrorData | {:type keyword, :status int, :body string, :parsed (map or nil)} — :parsed is the JSON-decoded response body when it decodes, else nil. Carried into the thrown ex-info's ex-data (§7) so callers can read Infisical's own error message without re-parsing :body themselves. |
clj-infisical.credentials — resolving client id/secretCalculations:
select-credential-source [inputs] -> Credentials | ErrorData
Pure decision function. inputs is a plain map assembled by the action
below (never touches the filesystem or env itself):
{:env {:client-id "..." :client-secret "..."} ; nil values allowed
:file {:dir-exists? bool
:dir-symlink? bool
:dir-not-group-or-other-writable? bool
:dir-owned-by-root? bool
:client-id-file {:exists? bool :symlink? bool
:no-group-or-other-bits? bool
:owned-by-process-user? bool
:content "..."}
:client-secret-file {...same shape...}}}
Precedence and rules (all pure, all unit-testable from this input map alone):
env.client-id and env.client-secret are non-blank →
{:source :env, ...}. Env wins outright; files are not consulted.env.client-id / env.client-secret is set →
:clj-infisical/ambiguous-credentials error (partial env config is
almost certainly a typo'd deploy, fail loudly rather than silently
falling through to files)./etc/infisical is owned by root; each credential file is
owned by the process's own effective user (a different owner than
the directory — root manages the directory, the running service owns
its own secret file). Checked in this order:
client-id-file.exists? and client-secret-file.exists?
are false → :clj-infisical/credentials-not-found. Nothing is
configured; that's absence, not a misconfiguration, so it's checked
before any security predicate runs (a directory that doesn't
exist can't meaningfully be "insecure").:clj-infisical/insecure-credential-files, naming the failing
path and check:
dir-exists?, not dir-symlink?, dir-owned-by-root?,
and dir-not-group-or-other-writable? (mode & 0o022 == 0).
Group/other read/execute on the directory is fine and
expected — it only exposes filenames, not secret contents, and
the process (running as a non-root user) needs to be able to
traverse it at all. This mirrors OpenSSH's own check on
~/.ssh: SSH rejects a .ssh directory or key file that's
group/other-writable, but does not require the directory
itself to be unreadable by others — confirmed as the intended
model.client_id, client_secret): exists?, not
symlink?, owned-by-process-user?, and
no-group-or-other-bits? (mode & 0o077 == 0, i.e. 0600 or
stricter) — these hold the actual secret bytes, so they get the
strict check. (This also covers the "exactly one of the two
files exists" case — a genuinely partial/inconsistent setup is
reported as insecure/misconfigured, not as "not found".)
Fail closed — never silently skip an insecure file and report
"not found" instead, because that hides a misconfiguration from
the operator.{:source :file, ...}.bits-clear? [mode-bits mask] -> bool — (zero? (bit-and mode-bits mask)).
One general helper used with two different masks (8r022 for the
directory's write check, 8r077 for each file's full check), rather than
two near-duplicate functions. Extracted as its own calculation because
it's the one bit of genuinely hidden logic (a "magic number") worth
naming and testing in isolation.
read-env [env-map] -> {:client-id ..., :client-secret ...} — pulls
"INFISICAL_CLIENT_ID"/"INFISICAL_CLIENT_SECRET" out of an already-read
environment map. Split out from read-env! (below) purely so the
"which two keys do we care about" logic is unit-testable with a plain map
literal instead of needing real process environment variables (which a
JVM test process can't set for itself mid-run).
Actions:
read-env! [] -> {:client-id ..., :client-secret ...} — thin wrapper,
(read-env (System/getenv)). System/getenv returns a java.util.Map,
which get/destructuring handle fine without conversion.stat-credential-files! [dir] -> inputs-shaped map (§ above, :file key only) —
does all filesystem interop (existence, POSIX permissions, owner,
symlink check, file read) for dir/client_id and dir/client_secret, and
returns it as data. No decisions made here.resolve-credentials! [] -> Credentials | (throws ex-info) — calls
read-env! and stat-credential-files! (short-circuiting: does not stat
the filesystem if env vars are already valid, since §5.2 rule 1 makes it
irrelevant — this is a small optimization but also avoids requiring
/etc/infisical to exist at all in the common env-var deployment case),
builds the inputs map, calls select-credential-source, and either
returns Credentials or throws ex-info from the error data.Credential file paths: /etc/infisical/client_id and
/etc/infisical/client_secret — confirmed.
clj-infisical.http — thin transport actionspost-json! [url json-body-map] -> {:status int, :body string} — wraps
clj-http-lite.client/post with Content-Type: application/json and
(json/write-str json-body-map) as the body, always
{:throw-exceptions false} (the library decides what's an error, not
clj-http-lite).get-json! [url query-params headers] -> {:status int, :body string} —
wraps clj-http-lite.client/get, same non-throwing contract.These two functions are the only things in the library allowed to import
clj-http-lite. Everything else depends on them, not on the HTTP library
directly — that's the boundary that lets §5.4/§5.5 calculations be tested
with plain maps instead of a mocked HTTP client.
clj-infisical.auth — Universal Auth loginCalculations:
login-request [site-url client-id client-secret] -> {:url ..., :json-body-map ...}
Builds the request map for post-json! — json-body-map is
{"clientId" client-id "clientSecret" client-secret}. Pure string/map
assembly.parse-login-response [{:keys [status body]}] -> AccessToken | ErrorData
Pure: data.json/read-str the body, and on 200 pull out
accessToken/expiresIn/tokenType into AccessToken (§5.1); on any
other status, return :clj-infisical/auth-failed ErrorData (§5.1)
carrying :status, :body, and :parsed (the body JSON-decoded, or
nil if it doesn't parse) so callers can read Infisical's own
message/error fields directly.Actions:
login! [site-url ^Credentials creds] -> AccessToken | (throws ex-info)
(-> (login-request site-url (:client-id creds) (:client-secret creds)) post-json! parse-login-response ...), throwing on error data. Thin wrapper, no decisions.clj-infisical.secrets — fetching a secretCalculations:
secret-request [^Config config ^AccessToken token secret-name] -> {:url ..., :query-params ..., :headers ...}
Pure request assembly per §3.2: url is
{site-url}/api/v3/secrets/raw/{secret-name}, query-params is
{"workspaceId" (:project-id config) "environment" ... "secretPath" ...}
— this is the one place :project-id (public API, §5.1/§5.6) gets
translated into workspaceId (the wire param this endpoint actually
expects, §3.2).parse-secret-response [{:keys [status body]}] -> Secret | ErrorData
Pure: 200 → keywordize the whole secret object into Secret (§5.1),
passing every key through, requiring only :secret-value to be present;
404 → :clj-infisical/secret-not-found; other non-2xx →
:clj-infisical/http-error; unparsable JSON, or a 200 body missing
secret.secretValue → :clj-infisical/invalid-response. Every ErrorData
branch carries :status/:body/:parsed per §5.1, same as
parse-login-response.Actions:
fetch-secret! [^Config config ^AccessToken token secret-name] -> Secret | (throws ex-info)
Thin wrapper: secret-request → get-json! → parse-secret-response →
throw-or-return.clj-infisical.core — public facadeTwo public entry points, sharing one private orchestration action so neither duplicates credential resolution / login / fetch:
-fetch-secret! [{:keys [project-id environment secret-path secret-name site-url client-id client-secret] :or {environment "dev" secret-path "/" site-url "https://app.infisical.com"}}] -> Secret | (throws ex-info)
(private, - prefix; not part of the public API). Orchestration only (an
action composed of other actions — no new decisions of its own):
project-id and secret-name are required; missing either throws
:clj-infisical/invalid-arguments before any I/O happens (cheap
calculation-style guard, checked first on purpose).client-id/client-secret are both supplied in the argument map,
use them directly as Credentials (:source :explicit) — this is the
seam that lets callers of this library unit-test their own code
against clj-infisical without env vars or /etc/infisical existing.
Otherwise call resolve-credentials! (§5.2).login! with those credentials.fetch-secret! for secret-name, returning the full Secret map.get-secret-raw! [args] -> Secret — (-fetch-secret! args), unchanged.
The "raw" half requested: the whole decoded secret object (§5.1),
keywordized keys, every field Infisical returned — not just the value.
Useful for the same reason the original curl script piped through jq
rather than assuming only secretValue existed: callers may want
:version, or other fields, without this library deciding in advance
they don't matter.
get-secret! [args] -> string — (:secret-value (-fetch-secret! args)).
The convenience wrapper most callers use — equivalent to
jq -r '.secret.secretValue' on top of get-secret-raw!.
Both are thin: the only "decision" either makes is get-secret!'s trivial
key pluck, which is itself pure and needs no separate calculation function
to justify unit-testing it in isolation.
clj-infisical.errors — shared ErrorData/unwrap machineryNot part of the original namespace list (§5.2–§5.6 above) — added during
implementation once credentials, auth, and secrets turned out to
independently reimplement the same two things: a safe "parse this response
body as JSON, or nil if it isn't" helper, and the (if (:type result) (throw ...) result) check every one of resolve-credentials!/login!/
fetch-secret! performs at its own action boundary. Recorded here so this
document stays the authoritative namespace list.
Calculations:
parse-json [body] -> map | nil — clojure.data.json/read-str, catching
parse failures and returning nil instead of throwing (an HTTP response
body is never guaranteed to be JSON). Used by auth/secrets for both
their success and error branches.error-data [type status body parsed] -> ErrorData — builds the
{:type :status :body :parsed} shape (§5.1) that every HTTP-originated
error branch in auth/secrets needs; centralizes that shape in one
place instead of five near-identical map literals.error? [result] -> bool — (keyword? (:type result)). Not a bare
(boolean (:type result)) (see §9 for the incident this caused): Secret
is an open passthrough of whatever Infisical returns (§5.1), and
Infisical's real secret objects carry a field literally named type
("shared"/"personal") — after keywordizing, :type "shared", a
string value. ErrorData's :type is always one of this library's own
:clj-infisical/... keywords, constructed by our own code and never
derived from parsed JSON (clojure.data.json/read-str never produces
keyword values) — so keyword? is the actual, safe discriminator; bare
truthiness is not.Actions:
unwrap! [message-prefix result] — returns result unchanged, or throws
an ex-info carrying it (message built from message-prefix + the error
:type) if error? is true. The one place all three of
resolve-credentials!, login!, and fetch-secret! turn a calculation's
returned ErrorData into a thrown one, replacing what was previously
three copies of the same three-line if.
credentials's insecure-credential-files/ambiguous-credentials/
credentials-not-found ErrorData (which carries :path/:reason, not
:status/:body/:parsed) uses unwrap! too — it only inspects :type,
so it's agnostic to which other keys an ErrorData variant carries.| Variable | Required | Meaning |
|---|---|---|
INFISICAL_CLIENT_ID | no (see §5.2) | Universal Auth client id |
INFISICAL_CLIENT_SECRET | no (see §5.2) | Universal Auth client secret |
project-id, environment, secret-path, site-url are not read
from the environment in v1 — they're per-call arguments, because this
library is meant to be embedded in other projects that may talk to multiple
projects/environments (and, per the corrected §3, is regularly used
against self-hosted instances, so site-url in particular must not silently
default to Infisical Cloud in a way a caller could forget to override).
Ambient config for those would be a footgun (silent cross-environment
reads). Revisit only if real usage shows this is annoying.
All errors are ex-info thrown with (ex-message ex) human-readable and
(ex-data ex) always carrying :type, so callers can
(case (:type (ex-data ex)) ...) instead of parsing strings. What else rides
along in ex-data depends on the error's origin (this is what §5.1's
ErrorData shape describes):
auth-failed, secret-not-found, http-error,
invalid-response): :status, :body (raw response body string), and
:parsed (the body JSON-decoded when possible, else nil) — this is the
mechanism for reading Infisical's actual message/error fields on a
400/500 without re-parsing anything yourself.credentials-not-found,
ambiguous-credentials, insecure-credential-files): no HTTP response
exists yet, so instead carries whatever's relevant to the failure, e.g.
:path and :reason for insecure-credential-files (naming which
check on which file/dir failed).invalid-arguments): :missing-keys.:type | Thrown by | Meaning |
|---|---|---|
:clj-infisical/invalid-arguments | get-secret!, get-secret-raw! | Missing project-id or secret-name |
:clj-infisical/credentials-not-found | resolve-credentials! | No env vars, no usable files |
:clj-infisical/ambiguous-credentials | resolve-credentials! | Exactly one of the two env vars set |
:clj-infisical/insecure-credential-files | resolve-credentials! | Dir/file exists but fails permission/owner/symlink check; :reason names which |
:clj-infisical/auth-failed | login! | Non-2xx from Universal Auth login |
:clj-infisical/secret-not-found | fetch-secret! | 404 from secret read |
:clj-infisical/http-error | login!, fetch-secret! | Any other non-2xx |
:clj-infisical/invalid-response | login!, fetch-secret! | 2xx but body isn't the JSON shape expected |
Given/When/Then list to drive deftest authoring, grouped by namespace.
Everything under "Calculations" needs no mocking — plain data in, plain data
(or thrown ex-info) out. Everything under "Actions" needs the corresponding
boundary function rebound (with-redefs) to fake I/O, since real
clj-http-lite/filesystem/env calls are out of scope for unit tests.
clj-infisical.credentials — calculationsbits-clear?
8r700 and mask 8r077, when checked, then true.8r600 and mask 8r077, when checked, then true.8r750 and mask 8r077, when checked, then false (group bits
set).8r704 and mask 8r077, when checked, then false (other-read
bit set).8r755 and mask 8r022, when checked, then true (group/other
read+execute is fine under the directory's write-only mask).8r775 and mask 8r022, when checked, then false (group-write
bit set).8r000 and either mask, when checked, then true.select-credential-source (never throws — always returns either
Credentials or ErrorData, same as parse-login-response/
parse-secret-response; it's the calling action,
resolve-credentials!, that turns ErrorData into a thrown ex-info)
Credentials with :source :env, and file inputs are ignored even if
they describe an insecure file.env.client-id set, when selected, then returns ErrorData
with :type :clj-infisical/ambiguous-credentials.env.client-secret set, then same as above (symmetry).0600), when selected, then
returns Credentials with :source :file and trimmed values.0775), when
selected, then :clj-infisical/insecure-credential-files naming the
directory.:clj-infisical/insecure-credential-files naming the directory.0755, root-owned), when selected (and files are otherwise secure),
then this does not by itself produce an error — proves directory
read/execute bits for group/other are permitted, only write bits and
ownership are checked on the directory.client_id file world-readable
(0644), when selected, then :clj-infisical/insecure-credential-files
naming that file.:clj-infisical/insecure-credential-files naming that file (ownership
direction is the opposite of the directory's — flagged explicitly since
it's easy to get backwards).:clj-infisical/insecure-credential-files.exists? false on both),
when selected, then :clj-infisical/credentials-not-found — checked
before any security predicate, so this holds even if the fixture also
sets e.g. dir-owned-by-root? to false.dir-exists? false, and
consequently both files exists? false), when selected, then
:clj-infisical/credentials-not-found, not an insecure-file error —
absence isn't insecurity.client-id-file.exists? true but
client-secret-file.exists? false (partial setup — only one file was
ever created), when selected, then :clj-infisical/insecure-credential-files,
not credentials-not-found — a partial setup is a misconfiguration
to flag, not a clean "nothing configured" state.Credentials values have no trailing whitespace.read-env
{"INFISICAL_CLIENT_ID" "cid" "INFISICAL_CLIENT_SECRET" "csecret"},
when called, then returns {:client-id "cid" :client-secret "csecret"}.{} (neither key present), when called, then returns
{:client-id nil :client-secret nil}.java.util.Map (what System/getenv actually returns, not a
Clojure map) with both keys present, when called, then still works —
proves read-env doesn't assume a Clojure-native map.clj-infisical.credentials — actionsread-env! — not independently unit-tested: it's a one-line wrapper,
(read-env (System/getenv)), and System/getenv can't be faked from
within the same JVM process without reflection hacks not worth the
complexity here. Its behavior is covered by read-env's tests above (the
logic) plus resolve-credentials!'s tests below (the integration point,
via rebinding read-env! itself, which — being public — needs no such
hack).
stat-credential-files!
0755 containing client_id
(mode 0600, content "abc\n") and client_secret (mode 0600,
content "xyz\n"), when called, then the returned map's
dir-not-group-or-other-writable?/no-group-or-other-bits?/exists?/
symlink? fields are all as expected and content includes the
trailing newline (trimming is the calculation's job, not this action's).exists? is
false for the dir and both files, with no exception thrown (absence
is data, not an I/O error).client_id is actually a symlink to another file, when called,
then symlink? is true regardless of the symlink target's own
permissions.root, so
dir-owned-by-root? will be false for every dir the test suite can
actually create — the "dir IS root-owned" branch can only be exercised
running as root (impractical/undesirable in CI). That branch is instead
covered at the select-credential-source calculation level (§8.1)
using a synthetic inputs map. This action's own test only needs to
confirm dir-owned-by-root? correctly reports false for a
known-non-root-owned dir, i.e. that the interop call works at all.resolve-credentials! (read-env! and stat-credential-files! both
rebound via with-redefs in every scenario below — real env vars/files
are never touched in unit tests)
read-env! rebound to return both client id/secret, when called,
then returns Credentials without touching the filesystem
(stat-credential-files! rebound to throw if invoked, to prove the
short-circuit in §5.2).read-env! rebound to return {:client-id nil :client-secret nil}
and stat-credential-files! rebound to return a fixture that
select-credential-source (real, unmocked) would reject, when called,
then throws ex-info with the matching :type from ex-data.clj-infisical.auth — calculationslogin-request
{site-url}/api/v1/auth/universal-auth/login and json-body-map
contains exactly {"clientId" ... "clientSecret" ...}.parse-login-response
{:status 200 :body "{\"accessToken\":\"t\",\"expiresIn\":7200,\"tokenType\":\"Bearer\"}"},
when parsed, then returns AccessToken with those three fields.{:status 401 :body "{\"message\":\"bad creds\"}"}, when parsed,
then :clj-infisical/auth-failed ErrorData with :status 401,
:body the raw string, and :parsed {"message" "bad creds"} — proving
the real Infisical error message survives unmangled.{:status 200 :body "not json"}, when parsed, then
:clj-infisical/invalid-response, not an uncaught JSON parse
exception.{:status 500 :body "<html>Bad Gateway</html>"} (a non-JSON error
body, e.g. from a proxy in front of Infisical), when parsed, then
:clj-infisical/auth-failed ErrorData with :parsed nil and :body
holding the raw HTML — proves a non-JSON error body degrades gracefully
instead of throwing from inside the parser.clj-infisical.auth — actionslogin!
post-json! rebound to return a canned 200 response, when
called, then returns the AccessToken parse-login-response would
produce from that body (i.e. it delegates, doesn't reimplement).post-json! rebound to return a 401, when called, then throws
ex-info of type :clj-infisical/auth-failed.clj-infisical.secrets — calculationssecret-request
Config and AccessToken and secret name, when built, then
url is {site-url}/api/v3/secrets/raw/{secret-name}, query-params
is exactly {"workspaceId" ... "environment" ... "secretPath" ...}
(no viewSecretValue, per §3.2), and headers has
Authorization: Bearer {token}.parse-secret-response
200 body with only secret.secretValue, when parsed, then
returns Secret {:secret-value "..."}.200 body with secret.secretValue plus other keys
(secretKey, version, ...), when parsed, then returns Secret with
all of those keys keywordized and present (:secret-key,
:version, ...) — proves the "raw" passthrough actually passes
everything through rather than narrowing to just the value.404, when parsed, then :clj-infisical/secret-not-found
ErrorData with :parsed populated from the body when it's JSON.500 with a JSON body {"message":"internal error"}, when
parsed, then :clj-infisical/http-error ErrorData with
:parsed {"message" "internal error"}.200 with a body missing the secret key, when parsed, then
:clj-infisical/invalid-response.200 with a secret object missing secretValue, when parsed,
then :clj-infisical/invalid-response.clj-infisical.secrets — actionsfetch-secret!
get-json! rebound to return a canned 200, when called, then
returns the Secret parse-secret-response would produce (all keys,
not just :secret-value).get-json! rebound to return a 404, when called, then throws
:clj-infisical/secret-not-found.clj-infisical.core — get-secret! / get-secret-raw! (action, integration-style with rebinding)These two share -fetch-secret! (§5.6), so most scenarios are phrased
against both; only the final return-shape scenario differs between them.
project-id missing from the argument map, when either is
called, then throws :clj-infisical/invalid-arguments without
calling resolve-credentials!, login!, or fetch-secret! (proves the
guard runs first — rebind all three to throw if invoked).client-id/client-secret supplied explicitly in the argument map,
when either is called, then resolve-credentials! is never invoked
(rebind it to throw if called) and the explicit values flow through to
login!.resolve-credentials! rebound to return a
fixed Credentials, login! rebound to return a fixed AccessToken, and
fetch-secret! rebound to return a fixed multi-key Secret
({:secret-value "s" :secret-key "k" :version 3}), when get-secret-raw!
is called, then it returns that Secret map unchanged, all three keys
intact.get-secret! is called instead, then it returns just
"s" — a plain string, not a map.resolve-credentials! (rebound) throws
:clj-infisical/credentials-not-found, when either is called, then that
exception propagates unchanged (no wrapping/swallowing).login! (rebound) throws :clj-infisical/auth-failed, when either
is called, then that exception propagates unchanged and fetch-secret! is
never invoked.environment/secret-path/site-url omitted, when either is
called, then the Config/URL built and passed downstream use the
documented defaults ("dev", "/", "https://app.infisical.com").Everything raised in earlier drafts is now resolved; nothing is blocking test-writing. Kept here as a record of why, not as open items:
/etc/infisical/client_id and
/etc/infisical/client_secret confirmed as-is.clj-http-lite has no
built-in retry-on-failure (only connect/socket timeouts), so a single
attempt per call, connection failures propagate uncaught.get-secret! return shape — reversed from the prior draft. That draft
dropped a second "raw" function on the reasoning that the confirmed
response only guaranteed secret.secretValue, so there was nothing else
to expose. That reasoning had it backwards: not-yet-confirmed fields are
exactly why "raw" access matters — the original curl script piped the
full response through jq rather than assuming its shape, and this
library shouldn't assume more than that script did. §5.6 now exposes both
get-secret! (string) and get-secret-raw! (the full Secret map, every
field Infisical returns, passed through), sharing one private
orchestration action. The same "don't discard what we can't yet confirm"
reasoning applies to errors: ErrorData (§5.1/§7) now carries the
JSON-decoded response body (:parsed) alongside the raw one, so a 400/500
response's real message/error fields are directly available instead
of forcing every caller to re-parse :body to get at Infisical's actual
error text.:workspace-id renamed to :project-id (§5.1/§3.2) — the public API
originally matched the v3 wire parameter (workspaceId) exactly, on the
reasoning that a name traceable 1:1 to the wire format needs no
translation layer when cross-referencing Infisical's own docs/errors/logs.
Revisited once it became clear the naming split runs deeper than
UI-vs-wire: Infisical's own API is inconsistent about this across
versions — v3 (/api/v3/secrets/raw, what this library calls) says
workspaceId; the newer v4 (/api/v4/secrets/{name}) says projectId,
matching current UI/CLI terminology. Since the library is unreleased (no
back-compat burden yet) and Infisical appears to be moving toward v4,
matching wire-format-of-the-moment was the wrong axis to optimize for.
:project-id now matches current terminology and insulates callers from
a future v3→v4 migration; secret-request (§5.5) is the one place
that translates :project-id into the workspaceId query param v3
still expects./etc/infisical,
no group/other write bits, group/other read+execute permitted. Modeled
explicitly on OpenSSH's own permission check on ~/.ssh, which rejects
group/other-writable directories/keys but doesn't require the directory
itself to be unreadable by others.infisical CLI or infisical agent has a fixed filesystem convention for
universal-auth credentials (e.g. always /etc/infisical/...). It doesn't:
the CLI proper only reads INFISICAL_TOKEN from the environment or
--client-id/--client-secret flags, with no file-based lookup at all.
The Agent supports reading credentials from files, but the paths are
arbitrary and set by the operator in its YAML config (example in the docs
uses relative paths like ./client-id) — there's no default location. So
there's no existing standard this library could align to or diverge from;
/etc/infisical/client_id + /etc/infisical/client_secret stand as
originally specified.error? misidentified successful secret fetches as
errors (§5.7) — discovered live, against a real self-hosted instance,
after the credential/TLS-trust issues were sorted out: get-secret! threw
:clj-infisical/secret-not-found for a secret that unambiguously existed.
Root cause: error? was (boolean (:type result)), and Secret (§5.1) is
an intentionally open passthrough of whatever Infisical's response
contains. Infisical's real secret objects carry a field literally named
type ("shared" vs. "personal"), which keywordize-camel turns into
:type "shared" — a truthy, non-nil value, so every successful fetch of
a shared secret was being thrown as an error. This is exactly the risk the
"pass everything through" design (§9, the get-secret!/get-secret-raw!
entry above) always carried and hadn't yet been forced to confront: an
open passthrough shape can collide with whatever internal convention is
used to tell it apart from a different shape. Fixed by tightening error?
to (keyword? (:type result)) — ErrorData's :type is always a keyword
we construct ourselves, never a value decoded from JSON, so this is a safe
discriminator where bare truthiness wasn't. Notably, §5.1 had already
documented ErrorData's :type as keyword all along — this was purely
an implementation gap between the documented shape and what the code
actually checked, not a design error. Added a dedicated errors_test.clj
(previously clj-infisical.errors had no direct unit tests of its own,
only indirect coverage via credentials/auth/secrets, none of whose
fixtures happened to include a field named type) plus a passthrough
regression case in secrets_test.clj reproducing the real response shape.test/clj_infisical/** should now be written straight from §8, before any
src/clj_infisical/** implementation exists.
This library is intended to be published to Clojars for consumption by other
projects, so a few things belong in scope even though they don't affect
src//test/ content:
project.clj — done: real :description, :url, MIT :license
(matching the user's stated default for all their projects).io.github.timotheosh/clj-infisical.
Clojars requires a verified group; a bare clj-infisical group (equal
to the artifact id) would need this account to already own that name,
which it doesn't. io.github.<username> is auto-verified the moment the
account logs into Clojars via GitHub OAuth as that user — no extra
domain/DNS verification step, and it matches the GitHub repo owner
(timotheosh) exactly. (net.clojars.<clojars-username> is the other
always-available option, tied to a Clojars account username rather than
GitHub — not used here since GitHub-based verification needed no separate
account-username decision.)project.clj is at 0.1.0 (no -SNAPSHOT) for
the first release; SNAPSHOT versions aren't resolvable off Clojars'
release repo.:deploy-repositories [["releases" :clojars] ["snapshots" :clojars]] —
added so a bare lein deploy (no explicit repo argument) also targets
Clojars; lein deploy clojars works without this too.README.org — done: real usage docs (§ already covered when this file
was written) plus install snippets for both Leiningen/Boot and deps.edn
using the io.github.timotheosh/clj-infisical coordinate.~/.lein/credentials.clj.gpg or
CLOJARS_USERNAME/CLOJARS_PASSWORD env vars); neither this spec nor
any assistant session should ever see or store the actual token.None of this blocks writing tests from §8 or implementing against them —
it's a pre-lein deploy checklist, not a design constraint on the code
itself.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |