@o3co/create-auth-policy-verifier 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,331 @@
1
1
  http {
2
- hostname = "0.0.0.0"
2
+ # Loopback by default: /verify answers with an authorization decision, so a
3
+ # reachable port is a decision oracle for anyone who can route to it. The
4
+ # deployment shape this template targets is a sidecar — the enforcement layer
5
+ # talks to the verifier over loopback and nothing else can.
6
+ #
7
+ # A containerised deployment must bind all interfaces to be reachable from
8
+ # another container: set HTTP_HOSTNAME=0.0.0.0 explicitly (docker-compose.yml
9
+ # does), and configure http.callerAuth below so the reachable port is not open.
10
+ hostname = "127.0.0.1"
3
11
  hostname = ${?HTTP_HOSTNAME}
12
+ # A whole number from 1 to 65535. Like every numeric setting in this file it
13
+ # is checked at boot, so a variable exported empty or holding something that
14
+ # is not a number fails here rather than binding an arbitrary free port.
4
15
  port = 3000
5
16
  port = ${?HTTP_PORT}
6
17
  pathPrefix = ""
7
18
  pathPrefix = ${?HTTP_PATH_PREFIX}
19
+
20
+ # Optional shared credential authenticating the SERVICE that calls /verify —
21
+ # a different question from the bearer token, which only establishes the
22
+ # subject a decision is about. Set HTTP_CALLER_AUTH_TOKEN and have the
23
+ # enforcement layer send the same value in the header below; the verifier then
24
+ # rejects every other caller before it does any decision work.
25
+ #
26
+ # Leaving the token unset leaves the endpoints open to anyone who can reach
27
+ # the port. That is supported (and is fine on loopback), but the server logs
28
+ # `unauthenticated_non_loopback_bind` at boot if the bind is not loopback.
29
+ callerAuth {
30
+ token = ${?HTTP_CALLER_AUTH_TOKEN}
31
+ # Not `Authorization` — that header carries the subject token.
32
+ header = "x-caller-token"
33
+ header = ${?HTTP_CALLER_AUTH_HEADER}
34
+ }
8
35
  }
9
36
 
10
37
  oauth {
11
38
  jwt {
12
39
  algorithm = "HS256"
13
40
  algorithm = ${?OAUTH_JWT_ALGORITHM}
41
+ # The HS256 shared secret. It must carry at least 32 bytes (256 bits) of
42
+ # key material or the process refuses to boot: HS256 is symmetric, so the
43
+ # value that verifies a token is the value that signs one, and guessing it
44
+ # buys the ability to MINT tokens for any subject. Generate it with
45
+ # `openssl rand -hex 32` (or -base64 32) and never write it in this file.
46
+ #
47
+ # The floor is measured on DECODED material at the smallest plausible
48
+ # reading: `openssl rand -hex 16` is 32 characters but only 16 bytes and is
49
+ # refused, and 32 random alphanumerics read as a 24-byte base64 body. The
50
+ # same floor applies to every previousSecrets entry below.
14
51
  secret = ${?OAUTH_JWT_SECRET}
52
+ # HS256 only. Names the secret above, matching the `kid` auth.provider
53
+ # stamps into every token it mints (its own default is "v0"). Leave it unset
54
+ # and the token header is never consulted — one secret verifies everything,
55
+ # which is the shape a deployment that has never rotated wants. Set it and a
56
+ # token carrying a different kid is refused, which is what makes the
57
+ # rotation block below unambiguous. Harmless under RS256/ES256/EdDSA, which
58
+ # match kid against the JWKS they fetch and ignore this key.
59
+ kid = ${?OAUTH_JWT_KID}
60
+ # HS256 only, and unlike `kid` this one is REFUSED at boot under
61
+ # RS256/ES256/EdDSA — an empty list included. Those rotate through the JWKS
62
+ # at jwksUri, which already carries every key the issuer publishes, so a
63
+ # previousSecrets block left behind on an asymmetric config configures
64
+ # nothing and is rejected rather than silently dropped.
65
+ #
66
+ # Secrets a rotation has retired but not finished retiring. Without them,
67
+ # rotating an HS256 secret is a coordinated outage: the moment the provider
68
+ # signs with a new value, every token already in flight is refused until
69
+ # both services have restarted together.
70
+ #
71
+ # Not declared here, and deliberately: nothing is being rotated yet, and an
72
+ # empty anchor in this shared file is exactly what would break a deployment
73
+ # that switches OAUTH_JWT_ALGORITHM to an asymmetric one. Add it to your own
74
+ # overlay while a rotation is open, then delete it once the window closed:
75
+ #
76
+ # oauth.jwt {
77
+ # kid = "v1" # the secret the provider signs with now
78
+ # secret = ${?OAUTH_JWT_SECRET}
79
+ # previousSecrets = [{
80
+ # kid = "v0" # the secret it signed with before
81
+ # secret = ${?OAUTH_JWT_PREVIOUS_SECRET}
82
+ # expiresAt = "2026-09-01T00:00:00Z" # access-token TTL + buffer
83
+ # }]
84
+ # }
85
+ #
86
+ # kid + secret + expiresAt is auth.provider's own previousSecrets shape, so
87
+ # the same pair of values moves on both sides. A token that carries no kid
88
+ # at all is tried against every secret listed here, which is why the list is
89
+ # capped at 3 entries — its length is the work one request can cost.
90
+ # Entries past their expiresAt stop verifying without a restart.
91
+ # JWKS endpoint for RS256/ES256/EdDSA. Must be https, because every key this
92
+ # endpoint hands back can verify tokens the service accepts: over plaintext,
93
+ # anyone on the network path swaps in their own signing key and forges tokens
94
+ # that pass. TLS is what proves the endpoint is the one you meant.
95
+ # Plaintext http is accepted only for loopback hosts (localhost, 127.0.0.0/8,
96
+ # [::1]), where no network path exists to attack — a service reached by
97
+ # container or DNS name is NOT loopback. Anything else fails at boot.
15
98
  jwksUri = ${?OAUTH_JWT_JWKS_URI}
99
+ # Bounds on the JWKS fetch, which happens inside a verify request whenever
100
+ # key resolution misses the cache. Leave them alone unless the provider's
101
+ # latency or rotation cadence demands otherwise.
102
+ jwksTimeoutMs = 5000
103
+ jwksTimeoutMs = ${?OAUTH_JWT_JWKS_TIMEOUT_MS}
104
+ # Minimum spacing between fetches. A token carrying an unknown kid triggers a
105
+ # refetch and kid is attacker-controlled, so this is also the throttle that
106
+ # keeps forged headers from becoming a fetch storm at the provider.
107
+ jwksCooldownMs = 30000
108
+ jwksCooldownMs = ${?OAUTH_JWT_JWKS_COOLDOWN_MS}
109
+ jwksCacheMaxAgeMs = 600000
110
+ jwksCacheMaxAgeMs = ${?OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS}
16
111
  publicKey = ${?OAUTH_JWT_PUBLIC_KEY}
17
112
  publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH}
18
- validate = true
19
- validate = ${?OAUTH_JWT_VALIDATE}
113
+
114
+ # RFC 9068 §4 — required whenever mode = "verify". No default: booting
115
+ # without them fails rather than accepting any validly-signed token.
116
+ # A single issuer, or a list in a config file when several are acceptable.
117
+ issuer = ${?OAUTH_JWT_ISSUER}
118
+ audience = ${?OAUTH_JWT_AUDIENCE}
119
+ # Accepted `typ` header. at+jwt is the RFC 9068 access-token type; pinning it
120
+ # rejects id_tokens, refresh tokens and logout tokens signed with the same key.
121
+ tokenType = "at+jwt"
122
+ tokenType = ${?OAUTH_JWT_TOKEN_TYPE}
123
+
124
+ # Bounds on a presented token's own lifetime. Both apply in EVERY mode,
125
+ # insecure-decode included. `exp` itself is required and has no setting:
126
+ # a token that never states when it stops being valid never stops.
127
+ #
128
+ # maxTokenAgeSeconds is the ceiling on how long after issuance (`iat`) a
129
+ # token may still be presented, whatever `exp` its issuer chose — the
130
+ # backstop against a decade-long access token. Setting it makes `iat`
131
+ # required, which RFC 9068 §2.2 requires of an access token anyway. A day
132
+ # is deliberately loose; lower it to match how long your tokens really
133
+ # live, and raise it only for an issuer that mints genuinely long ones.
134
+ maxTokenAgeSeconds = 86400
135
+ maxTokenAgeSeconds = ${?OAUTH_JWT_MAX_TOKEN_AGE_SECONDS}
136
+ # Skew allowance on every time-claim comparison (exp, nbf, and the age
137
+ # ceiling). 0 means the verifier trusts its clock and the issuer's to agree,
138
+ # which is right for a sidecar sharing a host. Where they are separate
139
+ # machines, 60 matches the skew the paired provider allows. Capped at 300:
140
+ # tolerance lengthens the accepted life of every token, so an unbounded
141
+ # value is a way to spell "expiry optional" without writing it down.
142
+ clockToleranceSeconds = 0
143
+ clockToleranceSeconds = ${?OAUTH_JWT_CLOCK_TOLERANCE_SECONDS}
144
+
145
+ # How bearer tokens are treated. "verify" (the default) fully verifies
146
+ # signature, iss, aud and the typ header. "insecure-decode" is TEST-ONLY:
147
+ # it accepts tokens without signature verification (exp/nbf are still
148
+ # enforced). The value itself is the consent — a mistyped env var can flip
149
+ # a boolean, but it cannot produce the literal string "insecure-decode".
150
+ # Never set insecure-decode in production.
151
+ mode = "verify"
152
+ mode = ${?OAUTH_JWT_MODE}
20
153
  }
21
154
  }
22
155
 
156
+ logging {
157
+ # Minimum level the process emits: trace/debug/info/warn/error/fatal, or
158
+ # silent to drop everything. Failure events the operator alerts on
159
+ # (jwt_verification_unavailable, verify_internal_error) emit at error.
160
+ #
161
+ # This is also the switch for the per-decision audit log. One `decision`
162
+ # event is emitted for every allow AND every deny, at info, carrying sub,
163
+ # resource, action, the rule that decided, the request id and the latency —
164
+ # so raising this to warn turns that stream off wholesale. It is deliberately
165
+ # not at warn: a deny is a normal outcome for a decision point, and routing
166
+ # it there would let any caller manufacture warn-level noise. The bearer
167
+ # token, the claims beyond sub, and the caller's `context` object are never
168
+ # on the line. See the README's Observability section.
169
+ level = "info"
170
+ level = ${?LOG_LEVEL}
171
+ }
172
+
23
173
  attribute {
174
+ # What a request is described by. Every rule below decides from these
175
+ # attributes and nothing else, so a rule whose attribute nothing here produces
176
+ # can never pass — see the note in the rule block.
24
177
  collectors = [
178
+ # The token's `scope` claim, split into the list the scope rule reads.
25
179
  { collector = "PayloadScopeCollector" }
180
+ # The token's `sub`, which becomes the `subject` on the decision response
181
+ # and the `sub` field on the audit log line.
26
182
  { collector = "PayloadSubjectIdCollector" }
27
183
  ]
184
+ # Deliberately absent: anything supplying permissions or roles. This
185
+ # deployment authorizes from the token's scopes alone. Adding a permission
186
+ # rule means adding its supplier here in the same edit (#113).
28
187
  }
29
188
 
30
189
  rule {
190
+ # Decision for a request that collects no rule at all. "deny" (the default)
191
+ # keeps the verifier fail-closed; "allow" is an explicit opt-out and makes
192
+ # any request that matches no rule succeed.
193
+ onEmptyRuleSet = "deny"
194
+ onEmptyRuleSet = ${?RULE_ON_EMPTY_RULE_SET}
195
+
196
+ # The shipped policy is the token's own `scope` claim and nothing else: the
197
+ # request is allowed exactly when the token carries "<action>:<resourceType>".
198
+ # It works against any issuer that mints scopes without an authorization store
199
+ # to stand up first, and it stays fail-closed — a token carrying no scope, or
200
+ # the wrong one, is denied, as is a request that collects no rule at all.
201
+ #
202
+ # Rules are grouped by kind and every GROUP must pass, so one group nothing can
203
+ # satisfy denies every request the deployment ever sees. That is what enabling
204
+ # a rule collector without the attribute collector feeding it does, and it is
205
+ # why the permission rule below is off rather than merely unconfigured (#113).
31
206
  collectors = [
207
+ # Scope matching is exact and case-sensitive (RFC 6749 §3.3): the token must
208
+ # carry "<action>:<resourceType>" verbatim. If your issuer emits bare resource
209
+ # names ("document") instead ("read:document"), opt into the rewrite
210
+ # explicitly — it is off by default because it invents an action the issuer
211
+ # never wrote:
212
+ # { collector = "ResourceActionScopeRuleCollector", allowBareScopeRewrite = true }
32
213
  { collector = "ResourceActionScopeRuleCollector" }
33
- { collector = "ResourceActionPermissionRuleCollector" }
214
+
215
+ # NOT enabled, and not an oversight. ResourceActionPermissionRuleCollector
216
+ # emits a rule requiring the permission "<resource.raw>.perm:<action>" —
217
+ # "project:1.perm:read" for resource "project:1", action "read" — and that
218
+ # rule reads the permissions/roles attributes, which nothing in the
219
+ # `attribute.collectors` above produces. Enabled on its own it is a group no
220
+ # token can satisfy, i.e. a verifier that answers deny to everything.
221
+ #
222
+ # So enable it together with a supplier, in one edit — two list entries that
223
+ # are one decision. Add this entry to `attribute.collectors` above:
224
+ #
225
+ # { collector = "StaticPermissionCollector", permissions = ["project:1.perm:read"] }
226
+ #
227
+ # and this entry to the list you are reading now:
228
+ #
229
+ # { collector = "ResourceActionPermissionRuleCollector" }
230
+ #
231
+ # StaticPermissionCollector grants one fixed list to every subject, so it
232
+ # suits a per-deployment capability, never a per-user one. Where permissions
233
+ # differ by subject they live in your own store: write an AttributeCollector
234
+ # that reads it and register it in a module (README.md, "Adding Custom
235
+ # Modules", and docs/extending.md).
34
236
  ]
35
237
  }
238
+
239
+ resource {
240
+ # How the request's `resource` string is turned into the resourceType that the
241
+ # scope rules above authorize. DotNotationResourceParser reads
242
+ # `type[:id]` segments separated by ".", and joins the segment types with "."
243
+ # — "project:1.member:2" is type "project.member", id "2". The separator is
244
+ # kept rather than rewritten to "_", so the nested type "a.b" and a flat type
245
+ # literally named "a_b" are two different namespaces.
246
+ #
247
+ # Strings outside that grammar are answered 400 invalid_request, never
248
+ # repaired: an empty segment ("a..b"), a second ":" in one segment ("a:1:2",
249
+ # which is refused rather than read as "a:1"), whitespace (" a:1 " is not
250
+ # trimmed), and any character an OAuth scope value cannot carry. If your
251
+ # callers send resource strings in some other syntax, register your own
252
+ # ResourceParser rather than relying on this one to interpret them.
253
+ parser = "DotNotationResourceParser"
254
+ }
255
+
256
+ verify {
257
+ # Cap on `POST /verify/batch` entries. The batch endpoint exists so filtering
258
+ # a list of N resources costs one round trip instead of N; the cap keeps one
259
+ # request from turning into an unbounded amount of pipeline work.
260
+ maxBatchSize = 50
261
+ maxBatchSize = ${?VERIFY_MAX_BATCH_SIZE}
262
+
263
+ # Ceiling on the JSON body, in bytes — the limit handed to `express.json()`.
264
+ # Below Express's unstated 100 KB default, and the outer envelope: it is what
265
+ # binds first on a large batch, since the per-field limits below bound ONE
266
+ # entry rather than N of them. A body over it is answered
267
+ # `413 payload_too_large` in the same deny envelope as everything else.
268
+ maxBodyBytes = 65536
269
+ maxBodyBytes = ${?VERIFY_MAX_BODY_BYTES}
270
+
271
+ # Ceilings on the two identifiers, in characters. Whitespace in either is
272
+ # refused rather than trimmed — they are concatenated into the
273
+ # `{action}:{resourceType}` scope an issuer has to have granted, and RFC 6749
274
+ # §3.3 makes space the delimiter between scope values.
275
+ maxResourceLength = 512
276
+ maxResourceLength = ${?VERIFY_MAX_RESOURCE_LENGTH}
277
+ maxActionLength = 64
278
+ maxActionLength = ${?VERIFY_MAX_ACTION_LENGTH}
279
+
280
+ # Bounds on the request `context`, which is free-form caller-supplied data.
281
+ # `maxContextEntries` counts every property and every array element in the
282
+ # whole tree, at every depth — nesting is bounded rather than forbidden, since
283
+ # RequestContextAttributeCollector reads dot paths such as "tenant.id" — and
284
+ # because each level of nesting costs at least one entry, it bounds the depth
285
+ # too. `maxContextValueLength` bounds every string in that tree, property
286
+ # names included.
287
+ maxContextEntries = 64
288
+ maxContextEntries = ${?VERIFY_MAX_CONTEXT_ENTRIES}
289
+ maxContextValueLength = 1024
290
+ maxContextValueLength = ${?VERIFY_MAX_CONTEXT_VALUE_LENGTH}
291
+
292
+ # Bounds on the collector fan-out both pipelines run for every decision.
293
+ # Collectors are the layer that calls databases and HTTP APIs, so they are the
294
+ # layer that can stall; without these, one collector holding a dead socket
295
+ # holds the whole decision.
296
+ #
297
+ # EXCEEDING ANY OF THEM DENIES the decision — `403 collector_timeout`, with an
298
+ # empty `reason.groups`. It is never answered with whatever was collected in
299
+ # time: fewer rules is a weaker policy, and no rules at all is an ALLOW
300
+ # wherever `rule.onEmptyRuleSet = "allow"` is set.
301
+ #
302
+ # `collectorTimeoutMs` is one collector's own budget, counted from when that
303
+ # collector starts rather than from when the fan-out did, so waiting behind
304
+ # the concurrency cap does not spend it.
305
+ collectorTimeoutMs = 2000
306
+ collectorTimeoutMs = ${?VERIFY_COLLECTOR_TIMEOUT_MS}
307
+
308
+ # `collectorDeadlineMs` is the whole fan-out, per pipeline. The per-collector
309
+ # budget cannot bound a SET: once collectors queue, enough of them each
310
+ # finishing just inside their own budget still adds up to a request nobody is
311
+ # waiting for any more.
312
+ collectorDeadlineMs = 5000
313
+ collectorDeadlineMs = ${?VERIFY_COLLECTOR_DEADLINE_MS}
314
+
315
+ # `collectorConcurrency` is how many run at once, per pipeline, per decision.
316
+ # Higher than any realistic collector set, so an ordinary configuration still
317
+ # fans out in one wave; what it removes is the tail, where a wide config or a
318
+ # full batch multiplies into simultaneous calls against a dependency that has
319
+ # just started to slow down.
320
+ collectorConcurrency = 8
321
+ collectorConcurrency = ${?VERIFY_COLLECTOR_CONCURRENCY}
322
+
323
+ # Whether collectors receive the raw credential as `context.credential`
324
+ # (#175). "never" (the default): collectors get verified claims only — the
325
+ # credential is replayable, and a collector that logs its context would leak
326
+ # a live token. Set "expose" ONLY when a project-side collector calls a
327
+ # downstream API as the subject (token forwarding / exchange); this line is
328
+ # then the greppable record of that decision. Never log the field.
329
+ credentialToCollectors = "never"
330
+ credentialToCollectors = ${?VERIFY_CREDENTIAL_TO_COLLECTORS}
331
+ }
@@ -0,0 +1,8 @@
1
+ # Environment overlay for development.
2
+ # Loaded when CONFIG_ENV (or NODE_ENV) is "development" — the default.
3
+ # Values here override application.conf.
4
+ #
5
+ # Most values are controlled via environment variables (see ${?VAR}
6
+ # substitutions in application.conf). Override entries here only when
7
+ # you need a fixed development-specific value that cannot come from
8
+ # an environment variable.
@@ -0,0 +1,9 @@
1
+ # Environment overlay for production.
2
+ # Loaded when CONFIG_ENV (or NODE_ENV) is "production".
3
+ # Values here override application.conf.
4
+ #
5
+ # Most production-sensitive values should be supplied via environment
6
+ # variables — see the ${?VAR} substitutions in application.conf.
7
+ #
8
+ # Override entries here only when a value must be fixed per-environment
9
+ # rather than per-deployment.
@@ -8,6 +8,13 @@ services:
8
8
  volumes:
9
9
  - ./src:/home/node/src
10
10
  - ./config:/home/node/config
11
+ environment:
12
+ # application.conf binds loopback by default, which inside a container
13
+ # means "reachable from nothing" — the published port would never connect.
14
+ # Binding all interfaces is therefore an explicit opt-in per deployment,
15
+ # and it is why HTTP_CALLER_AUTH_TOKEN is worth setting in .env: past the
16
+ # container boundary, anyone who can reach the port can ask for decisions.
17
+ - HTTP_HOSTNAME=0.0.0.0
11
18
  env_file:
12
19
  - path: .env
13
20
  required: false
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-policy-verifier-standalone",
3
- "version": "0.0.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "build": "tsc",
13
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
13
14
  "start": "node dist/main.mjs",
14
15
  "test": "vitest run",
15
16
  "debug": "NODE_OPTIONS='--conditions=development' tsx watch src/main.mts"
@@ -18,17 +19,18 @@
18
19
  "@o3co/auth.policy-verifier.core": "workspace:*",
19
20
  "@o3co/auth.policy-verifier.builtins": "workspace:*",
20
21
  "@o3co/auth.policy-verifier.server": "workspace:*",
21
- "@o3co/auth.utils": "^0.0.2",
22
- "@o3co/ts.hocon": "^1.1.0",
23
- "express": "^5.2.1"
22
+ "@o3co/auth.utils": "^0.0.4",
23
+ "@o3co/ts.hocon": "^1.12.0",
24
+ "express": "^5.2.1",
25
+ "pino": "^10.3.1"
24
26
  },
25
27
  "devDependencies": {
26
- "@types/node": "^25.1.0",
27
- "@types/supertest": "^7.2.0",
28
- "jose": "^6.2.2",
28
+ "@types/node": "^26.2.0",
29
+ "@types/supertest": "^7.2.1",
30
+ "jose": "^6.2.8",
29
31
  "supertest": "^7.2.2",
30
- "tsx": "^4.21.0",
32
+ "tsx": "^4.23.11",
31
33
  "typescript": "^5.9.3",
32
- "vitest": "^4.1.2"
34
+ "vitest": "^4.1.10"
33
35
  }
34
36
  }
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { resolveConfigPaths } from "../configPath.js";
6
+
7
+ describe("resolveConfigPaths", () => {
8
+ it("accepts a configDirPath with a trailing slash", () => {
9
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(
10
+ "/home/node/templates/standalone/config/",
11
+ "production",
12
+ );
13
+ expect(applicationConfPath).toBe("/home/node/templates/standalone/config/application.conf");
14
+ expect(envConfPath).toBe("/home/node/templates/standalone/config/production.conf");
15
+ });
16
+
17
+ it("accepts a configDirPath without a trailing slash", () => {
18
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(
19
+ "/home/node/templates/standalone/config",
20
+ "development",
21
+ );
22
+ expect(applicationConfPath).toBe("/home/node/templates/standalone/config/application.conf");
23
+ expect(envConfPath).toBe("/home/node/templates/standalone/config/development.conf");
24
+ });
25
+
26
+ it("rejects env names that resolve outside configDirPath", () => {
27
+ expect(() =>
28
+ resolveConfigPaths("/home/node/templates/standalone/config/", "../secrets"),
29
+ ).toThrow(/resolves outside/);
30
+ });
31
+
32
+ it("rejects env names containing a path separator", () => {
33
+ expect(() =>
34
+ resolveConfigPaths("/home/node/templates/standalone/config/", "nested/env"),
35
+ ).toThrow(/resolves outside/);
36
+ });
37
+ });