@nacre.work/core 0.2.0 → 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.
- package/dist/authz/index.d.ts +0 -1
- package/dist/authz/index.d.ts.map +1 -1
- package/dist/authz/index.js +0 -1
- package/dist/authz/index.js.map +1 -1
- package/dist/config.d.ts +110 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +347 -34
- package/dist/config.js.map +1 -1
- package/dist/db/migrate.d.ts +45 -0
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +61 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/extensions.d.ts +298 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +246 -0
- package/dist/extensions.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +10 -7
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +25 -7
- package/dist/metrics.js.map +1 -1
- package/dist/migrations/0015_reference_queries.sql +97 -0
- package/dist/migrations/0016_drop_acl_tag_cache.sql +62 -0
- package/dist/migrations/0017_drop_workspace_admin_role.sql +30 -0
- package/dist/migrations/0018_group_members_nulls_not_distinct.sql +46 -0
- package/dist/migrations/0019_embedding_provider_write_guard.sql +58 -0
- package/dist/migrations/0020_document_content_type.sql +19 -0
- package/dist/multipart.d.ts +55 -0
- package/dist/multipart.d.ts.map +1 -0
- package/dist/multipart.js +158 -0
- package/dist/multipart.js.map +1 -0
- package/dist/oauth.d.ts +10 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +10 -0
- package/dist/oauth.js.map +1 -1
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +24 -46
- package/dist/observability.js.map +1 -1
- package/dist/reindex.d.ts +44 -0
- package/dist/reindex.d.ts.map +1 -1
- package/dist/reindex.js +49 -0
- package/dist/reindex.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/vector/query.d.ts +7 -9
- package/dist/vector/query.d.ts.map +1 -1
- package/dist/vector/query.js +7 -6
- package/dist/vector/query.js.map +1 -1
- package/dist/vector/search.d.ts +49 -1
- package/dist/vector/search.d.ts.map +1 -1
- package/dist/vector/search.js +83 -1
- package/dist/vector/search.js.map +1 -1
- package/package.json +1 -1
- package/dist/authz/tags.d.ts +0 -40
- package/dist/authz/tags.d.ts.map +0 -1
- package/dist/authz/tags.js +0 -48
- package/dist/authz/tags.js.map +0 -1
package/dist/authz/index.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export { buildFilter, METADATA_PREFIX } from './filter.js';
|
|
|
9
9
|
export type { FilterClause, MetadataValue, Narrowing, QueryablePlan, VectorFilter } from './filter.js';
|
|
10
10
|
export { ancestry, MemoryScopeTree } from './scope-tree.js';
|
|
11
11
|
export type { ScopeTree, ScopeTreeData } from './scope-tree.js';
|
|
12
|
-
export { aclTags, principalTag, DEFAULT_TAG_BYTES } from './tags.js';
|
|
13
12
|
export { cachedEffectivePrincipals, MemoryCache, RedisCache, principalsCacheKey } from './cache.js';
|
|
14
13
|
export type { CacheStore, CachedPrincipalsOptions } from './cache.js';
|
|
15
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../authz/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AACvE,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACjE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC1D,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AACtG,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAC3D,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/D,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../authz/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AACvE,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACjE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AACpD,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAC1D,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AACtG,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAC3D,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/D,OAAO,EAAE,yBAAyB,EAAE,WAAW,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACnG,YAAY,EAAE,UAAU,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/authz/index.js
CHANGED
|
@@ -4,6 +4,5 @@ export { resolve } from './resolve.js';
|
|
|
4
4
|
export { referenceAllows } from './reference.js';
|
|
5
5
|
export { buildFilter, METADATA_PREFIX } from './filter.js';
|
|
6
6
|
export { ancestry, MemoryScopeTree } from './scope-tree.js';
|
|
7
|
-
export { aclTags, principalTag, DEFAULT_TAG_BYTES } from './tags.js';
|
|
8
7
|
export { cachedEffectivePrincipals, MemoryCache, RedisCache, principalsCacheKey } from './cache.js';
|
|
9
8
|
//# sourceMappingURL=index.js.map
|
package/dist/authz/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../authz/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAEvE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE1D,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAE3D,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../authz/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAEvE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE1D,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAE3D,OAAO,EAAE,yBAAyB,EAAE,WAAW,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/config.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* obviously right value — and never fine for anything naming a host or
|
|
15
15
|
* carrying a credential.
|
|
16
16
|
*/
|
|
17
|
+
import { type KeyObject } from 'node:crypto';
|
|
17
18
|
export interface Config {
|
|
18
19
|
readonly env: 'development' | 'production';
|
|
19
20
|
readonly canonicalUrl: string;
|
|
@@ -30,6 +31,14 @@ export interface Config {
|
|
|
30
31
|
readonly oauthAuthorizationServer: string;
|
|
31
32
|
readonly logLevel: 'debug' | 'info' | 'warn' | 'error';
|
|
32
33
|
readonly logFormat: 'json' | 'text';
|
|
34
|
+
/**
|
|
35
|
+
* Packages to import at startup so they can register extensions.
|
|
36
|
+
*
|
|
37
|
+
* Named rather than imported, because this repository's CI fails if its
|
|
38
|
+
* source mentions the commercial package at all — see `extensions.ts`. Empty
|
|
39
|
+
* is the default and is the whole open-core product.
|
|
40
|
+
*/
|
|
41
|
+
readonly modules: readonly string[];
|
|
33
42
|
readonly pgUrl: string;
|
|
34
43
|
readonly pgPoolMax: number;
|
|
35
44
|
readonly qdrantUrl: string;
|
|
@@ -48,8 +57,6 @@ export interface Config {
|
|
|
48
57
|
readonly accessTokenTtl: number;
|
|
49
58
|
readonly refreshTokenTtl: number;
|
|
50
59
|
readonly aclCacheTtl: number;
|
|
51
|
-
readonly aclPropagationSla: number;
|
|
52
|
-
readonly aclTagHashBytes: number;
|
|
53
60
|
/**
|
|
54
61
|
* How long a tombstoned document keeps its vectors before the sweep removes
|
|
55
62
|
* them. A tunable, so a default is fine — and it is not a correctness knob:
|
|
@@ -81,6 +88,13 @@ export interface Config {
|
|
|
81
88
|
readonly auditQueryText: boolean;
|
|
82
89
|
/** The reindex rollback window: how long a superseded collection survives. */
|
|
83
90
|
readonly collectionRetentionDays: number;
|
|
91
|
+
/**
|
|
92
|
+
* The recall a reindex must reach before its layer switches, as a fraction.
|
|
93
|
+
*
|
|
94
|
+
* Read from a whole-number percentage. Applies only to a layer that has a
|
|
95
|
+
* reference query set — there is no gate without one.
|
|
96
|
+
*/
|
|
97
|
+
readonly reindexMinRecall: number;
|
|
84
98
|
/** How long a presigned link to a document's bytes stays valid. */
|
|
85
99
|
readonly presignTtl: number;
|
|
86
100
|
/**
|
|
@@ -112,11 +126,103 @@ export interface Config {
|
|
|
112
126
|
*/
|
|
113
127
|
export interface JwtKeys {
|
|
114
128
|
/** Everything issued from now on is signed with this. */
|
|
115
|
-
readonly
|
|
129
|
+
readonly signing: KeyObject | Uint8Array;
|
|
130
|
+
/**
|
|
131
|
+
* What a token is verified against.
|
|
132
|
+
*
|
|
133
|
+
* The same value as `signing` for a shared secret, and the **public half**
|
|
134
|
+
* for Ed25519 — which is the whole reason the asymmetric mode is worth
|
|
135
|
+
* having. A process that only verifies never needs the private key, so the
|
|
136
|
+
* blast radius of reading a container's environment stops at "can check
|
|
137
|
+
* tokens" instead of "can mint them".
|
|
138
|
+
*/
|
|
139
|
+
readonly verification: KeyObject | Uint8Array;
|
|
116
140
|
/** Accepted on verification, never used to sign. Empty outside a rotation. */
|
|
117
|
-
readonly alsoAccept: readonly Uint8Array[];
|
|
141
|
+
readonly alsoAccept: readonly (KeyObject | Uint8Array)[];
|
|
142
|
+
/**
|
|
143
|
+
* Pinned, and passed to `jwtVerify` rather than left to the token's header.
|
|
144
|
+
*
|
|
145
|
+
* A verifier that accepts whatever `alg` a token claims is the classic JWT
|
|
146
|
+
* mistake. `jose` already refuses the worst shapes — `none`, and an HMAC
|
|
147
|
+
* algorithm against an asymmetric `KeyObject` — but "the library happens to
|
|
148
|
+
* stop it" is not the same statement as "this deployment accepts exactly one
|
|
149
|
+
* algorithm", and only the second one survives a dependency upgrade.
|
|
150
|
+
*/
|
|
151
|
+
readonly algorithm: 'HS256' | 'EdDSA';
|
|
152
|
+
/**
|
|
153
|
+
* The current key's `kid`, put in the header of every token this process
|
|
154
|
+
* signs so a JWKS consumer can select rather than try each. Absent for HMAC,
|
|
155
|
+
* where it would name nothing anyone could fetch.
|
|
156
|
+
*/
|
|
157
|
+
readonly keyId?: string;
|
|
158
|
+
/**
|
|
159
|
+
* What `/.well-known/jwks.json` serves. Absent for HMAC.
|
|
160
|
+
*
|
|
161
|
+
* A shared secret has no publishable half, and serving one would be serving
|
|
162
|
+
* the signing key — so this is optional rather than always present, and the
|
|
163
|
+
* endpoint exists only when there is something safe to put in it.
|
|
164
|
+
*
|
|
165
|
+
* The retired key is in here too during a rotation, for the same reason it is
|
|
166
|
+
* still accepted: there are tokens in the wild signed with it, and a verifier
|
|
167
|
+
* outside this process has to be able to check them. Assembled here rather
|
|
168
|
+
* than by each caller, because a JWKS missing the previous key is a rotation
|
|
169
|
+
* that breaks every external verifier at the restart.
|
|
170
|
+
*/
|
|
171
|
+
readonly jwks?: readonly Record<string, unknown>[];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* What a process that only *verifies* tokens needs — the verification key, the
|
|
175
|
+
* keys still accepted during a rotation, and the one algorithm it will honour.
|
|
176
|
+
* No `signing`, no `jwks` to publish: a resource server such as the MCP
|
|
177
|
+
* transport checks tokens and never mints them, and `loadJwtVerification` lets
|
|
178
|
+
* it be configured with only the public half, so reading its environment gets
|
|
179
|
+
* an attacker to "can check tokens" and no further.
|
|
180
|
+
*/
|
|
181
|
+
export interface JwtVerification {
|
|
182
|
+
readonly verification: KeyObject | Uint8Array;
|
|
183
|
+
readonly alsoAccept: readonly (KeyObject | Uint8Array)[];
|
|
184
|
+
readonly algorithm: 'HS256' | 'EdDSA';
|
|
118
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* A short, non-reversible name for whichever key is in use, for a startup line.
|
|
188
|
+
*
|
|
189
|
+
* An operator needs to know *which* key a process loaded when two environments
|
|
190
|
+
* disagree — and nobody needs the key itself in a log. `api` and `mcp` each had
|
|
191
|
+
* their own copy of this, taking a `Uint8Array`; two copies of a function that
|
|
192
|
+
* hashes a secret is two chances for one of them to log the wrong thing.
|
|
193
|
+
*
|
|
194
|
+
* For an asymmetric key it is a digest of the **public** half, which means the
|
|
195
|
+
* value printed by a process holding the private key matches the one printed by
|
|
196
|
+
* a process holding only the public key. That is precisely the comparison this
|
|
197
|
+
* exists to let someone make.
|
|
198
|
+
*/
|
|
199
|
+
export declare function keyFingerprint(key: KeyObject | Uint8Array): string;
|
|
119
200
|
export declare function loadJwtKeys(env?: NodeJS.ProcessEnv): JwtKeys;
|
|
201
|
+
/**
|
|
202
|
+
* Verification-only key material, for a process that checks tokens and never
|
|
203
|
+
* signs one — the MCP transport, which is a resource server.
|
|
204
|
+
*
|
|
205
|
+
* Three ways to name the key, exactly one of them at a time:
|
|
206
|
+
*
|
|
207
|
+
* - `NACRE_JWT_PUBLIC_KEY_REF` — the Ed25519 **public** key, and nothing else.
|
|
208
|
+
* This is the one that makes the asymmetric mode worth what it claims: the
|
|
209
|
+
* verifier holds no secret and no private key, so an attacker who reads its
|
|
210
|
+
* environment can check tokens and cannot mint them. `NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF`
|
|
211
|
+
* is its rotation overlap, mirroring the private side.
|
|
212
|
+
* - `NACRE_JWT_PRIVATE_KEY_REF` — the private key, from which the public half is
|
|
213
|
+
* derived. Accepted so a process co-located with the signer, or one that
|
|
214
|
+
* simply has the private key, still verifies; it just does not get the
|
|
215
|
+
* separation the public-only path does.
|
|
216
|
+
* - `NACRE_JWT_SECRET` — the shared secret. Verifying and signing are the same
|
|
217
|
+
* key here by definition, so there is no separation to be had and the process
|
|
218
|
+
* holds the secret regardless; this exists so a Compose deployment that runs
|
|
219
|
+
* on a shared secret still starts.
|
|
220
|
+
*
|
|
221
|
+
* Setting more than one is refused, for the same reason `loadJwtKeys` refuses
|
|
222
|
+
* two answers to "what signs a token": whichever won, the others would be
|
|
223
|
+
* configured, apparently in use, and ignored.
|
|
224
|
+
*/
|
|
225
|
+
export declare function loadJwtVerification(env?: NodeJS.ProcessEnv): JwtVerification;
|
|
120
226
|
export declare class ConfigError extends Error {
|
|
121
227
|
readonly problems: readonly string[];
|
|
122
228
|
constructor(problems: readonly string[]);
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,GAAG,EAAE,aAAa,GAAG,YAAY,CAAA;IAC1C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B;;;;;;;;;OASG;IACH,QAAQ,CAAC,wBAAwB,EAAE,MAAM,CAAA;IAIzC,QAAQ,CAAC,QAAQ,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;IACtD,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;IAEnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IACzC,QAAQ,CAAC,aAAa,EAAE,YAAY,GAAG,QAAQ,CAAA;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IAEzB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;IACjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IAEjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAEhC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"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"}
|
package/dist/config.js
CHANGED
|
@@ -14,22 +14,218 @@
|
|
|
14
14
|
* obviously right value — and never fine for anything naming a host or
|
|
15
15
|
* carrying a credential.
|
|
16
16
|
*/
|
|
17
|
+
import { createHash, createPrivateKey, createPublicKey } from 'node:crypto';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
/** A stable identifier for a key, from its public bytes. Never from the secret. */
|
|
21
|
+
function fingerprint(der) {
|
|
22
|
+
return createHash('sha256').update(der).digest('base64url').slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One public key as a JWK.
|
|
26
|
+
*
|
|
27
|
+
* `kid` is derived from the public bytes rather than configured, so it is
|
|
28
|
+
* stable across restarts and identical in every process without anyone having
|
|
29
|
+
* to keep two settings in step.
|
|
30
|
+
*/
|
|
31
|
+
function publicJwk(key) {
|
|
32
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
33
|
+
return {
|
|
34
|
+
...key.export({ format: 'jwk' }),
|
|
35
|
+
kid: fingerprint(der),
|
|
36
|
+
use: 'sig',
|
|
37
|
+
alg: 'EdDSA',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A short, non-reversible name for whichever key is in use, for a startup line.
|
|
42
|
+
*
|
|
43
|
+
* An operator needs to know *which* key a process loaded when two environments
|
|
44
|
+
* disagree — and nobody needs the key itself in a log. `api` and `mcp` each had
|
|
45
|
+
* their own copy of this, taking a `Uint8Array`; two copies of a function that
|
|
46
|
+
* hashes a secret is two chances for one of them to log the wrong thing.
|
|
47
|
+
*
|
|
48
|
+
* For an asymmetric key it is a digest of the **public** half, which means the
|
|
49
|
+
* value printed by a process holding the private key matches the one printed by
|
|
50
|
+
* a process holding only the public key. That is precisely the comparison this
|
|
51
|
+
* exists to let someone make.
|
|
52
|
+
*/
|
|
53
|
+
export function keyFingerprint(key) {
|
|
54
|
+
if (key instanceof Uint8Array) {
|
|
55
|
+
return `sha256:${createHash('sha256').update(key).digest('hex').slice(0, 12)}`;
|
|
56
|
+
}
|
|
57
|
+
const der = (key.type === 'private' ? createPublicKey(key) : key).export({
|
|
58
|
+
type: 'spki',
|
|
59
|
+
format: 'der',
|
|
60
|
+
});
|
|
61
|
+
return `ed25519:${fingerprint(der)}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One Ed25519 key from a `file://` reference, plus its public half.
|
|
65
|
+
*
|
|
66
|
+
* `file://` only, and that is the whole of the scheme support. The variable is
|
|
67
|
+
* named `_REF` because `docs/config.md` requires secrets to be references into
|
|
68
|
+
* a secret store rather than values, and every platform that has one — Docker
|
|
69
|
+
* secrets, Kubernetes, systemd credentials — presents it as a file. A `vault://`
|
|
70
|
+
* or `aws-kms://` scheme would be a network client on the startup path, which
|
|
71
|
+
* is a different feature with different failure modes.
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* Read the PEM a `file://` reference names. Shared by the private- and
|
|
75
|
+
* public-key loaders so the `file://`-only rule and its error wording live in
|
|
76
|
+
* one place; `kind` only changes the noun in the messages.
|
|
77
|
+
*/
|
|
78
|
+
function readPemRef(ref, variable, kind) {
|
|
79
|
+
let path;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(ref);
|
|
82
|
+
if (url.protocol !== 'file:') {
|
|
83
|
+
throw new Error(`scheme ${url.protocol} is not supported`);
|
|
84
|
+
}
|
|
85
|
+
path = fileURLToPath(url);
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
throw new ConfigError([
|
|
89
|
+
`${variable} is not a file:// URL: ${String(cause)}. ` +
|
|
90
|
+
`It names a file holding a PEM ${kind} key, for example ` +
|
|
91
|
+
'file:///run/secrets/jwt_ed25519.',
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return readFileSync(path, 'utf8');
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
throw new ConfigError([
|
|
99
|
+
`${variable} names ${path}, which cannot be read: ${String(cause)}.`,
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The public half of an Ed25519 key, from a `file://` reference to a PEM public
|
|
105
|
+
* key. This is what a process that only *verifies* tokens needs — and all it
|
|
106
|
+
* needs, which is the point: reading its environment gets an attacker to "can
|
|
107
|
+
* check tokens" and no further. See `loadJwtVerification`.
|
|
108
|
+
*/
|
|
109
|
+
function loadEd25519Public(ref, variable) {
|
|
110
|
+
const pem = readPemRef(ref, variable, 'public');
|
|
111
|
+
// `createPublicKey` will happily extract the public half from a *private* PEM,
|
|
112
|
+
// which would let an operator paste the private key into the public-key
|
|
113
|
+
// variable and never notice — leaving the file this variable exists to keep
|
|
114
|
+
// off this process sitting right on it. Refuse it by name: the whole point of
|
|
115
|
+
// a verify-only process is that the private key is nowhere near it.
|
|
116
|
+
if (/PRIVATE KEY/.test(pem)) {
|
|
117
|
+
throw new ConfigError([
|
|
118
|
+
`${variable} names a file that contains a PRIVATE key. This variable is for ` +
|
|
119
|
+
'the public half only — a process that verifies must not be given the ' +
|
|
120
|
+
'signing key. Export the public half with ' +
|
|
121
|
+
'`openssl pkey -in jwt_ed25519.pem -pubout -out jwt_ed25519.pub`.',
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
let key;
|
|
125
|
+
try {
|
|
126
|
+
key = createPublicKey(pem);
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
throw new ConfigError([
|
|
130
|
+
`${variable} names a file that is not a PEM public key: ${String(cause)}. ` +
|
|
131
|
+
'Export one from the signing key with ' +
|
|
132
|
+
'`openssl pkey -in jwt_ed25519.pem -pubout -out jwt_ed25519.pub`.',
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
if (key.asymmetricKeyType !== 'ed25519') {
|
|
136
|
+
throw new ConfigError([
|
|
137
|
+
`${variable} names a ${String(key.asymmetricKeyType)} public key. Only Ed25519 ` +
|
|
138
|
+
'is accepted — it must match the signing key, which is Ed25519.',
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
return key;
|
|
142
|
+
}
|
|
143
|
+
function loadEd25519(ref, variable) {
|
|
144
|
+
const pem = readPemRef(ref, variable, 'private');
|
|
145
|
+
let key;
|
|
146
|
+
try {
|
|
147
|
+
key = createPrivateKey(pem);
|
|
148
|
+
}
|
|
149
|
+
catch (cause) {
|
|
150
|
+
throw new ConfigError([
|
|
151
|
+
`${variable} names a file that is not a PEM private key: ${String(cause)}.`,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
if (key.asymmetricKeyType !== 'ed25519') {
|
|
155
|
+
// One algorithm, refused rather than adapted. RSA needs a size check and a
|
|
156
|
+
// padding choice, and EC needs a curve-to-algorithm mapping — each a place
|
|
157
|
+
// to be wrong about something a token's signature depends on. Ed25519 has
|
|
158
|
+
// no parameters, and it is what `docs/config.md` has named in its example
|
|
159
|
+
// since the variable was written.
|
|
160
|
+
throw new ConfigError([
|
|
161
|
+
`${variable} names a ${String(key.asymmetricKeyType)} key. Only Ed25519 is ` +
|
|
162
|
+
'accepted: it has no parameters to get wrong, and it is what the example ' +
|
|
163
|
+
'in docs/config.md names. Generate one with ' +
|
|
164
|
+
'`openssl genpkey -algorithm ed25519 -out jwt_ed25519.pem`.',
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
return { private: key, public: createPublicKey(key) };
|
|
168
|
+
}
|
|
17
169
|
export function loadJwtKeys(env = process.env) {
|
|
18
|
-
// Development uses a symmetric secret. Production is meant to load an Ed25519
|
|
19
|
-
// key through NACRE_JWT_PRIVATE_KEY_REF; until that lands, refusing is the
|
|
20
|
-
// honest behaviour — a hardcoded fallback here would be a signing key anyone
|
|
21
|
-
// reading the source can forge tokens with.
|
|
22
170
|
const secret = env.NACRE_JWT_SECRET;
|
|
171
|
+
const ref = env.NACRE_JWT_PRIVATE_KEY_REF;
|
|
172
|
+
if (secret !== undefined && secret !== '' && ref !== undefined && ref !== '') {
|
|
173
|
+
// Refused rather than resolved by precedence. Whichever one this picked, the
|
|
174
|
+
// other would be configured, apparently in use, and silently ignored — and
|
|
175
|
+
// the operator would find out when the tokens they expected to be Ed25519
|
|
176
|
+
// turned out to be HMAC, or the other way round.
|
|
177
|
+
throw new ConfigError([
|
|
178
|
+
'NACRE_JWT_SECRET and NACRE_JWT_PRIVATE_KEY_REF are both set. They are two ' +
|
|
179
|
+
'answers to "what signs a token" and there is no order of precedence ' +
|
|
180
|
+
'worth inventing. Set one.',
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
if (ref !== undefined && ref !== '') {
|
|
184
|
+
const current = loadEd25519(ref, 'NACRE_JWT_PRIVATE_KEY_REF');
|
|
185
|
+
const der = current.public.export({ type: 'spki', format: 'der' });
|
|
186
|
+
const previousRef = env.NACRE_JWT_PREVIOUS_KEY_REF;
|
|
187
|
+
const previous = previousRef === undefined || previousRef === ''
|
|
188
|
+
? undefined
|
|
189
|
+
: loadEd25519(previousRef, 'NACRE_JWT_PREVIOUS_KEY_REF');
|
|
190
|
+
if (previous !== undefined &&
|
|
191
|
+
previous.public.export({ type: 'spki', format: 'der' }).equals(der)) {
|
|
192
|
+
// The same refusal the symmetric path makes, for the same reason: this is
|
|
193
|
+
// what an operator does when they mean to rotate and copy the wrong path,
|
|
194
|
+
// and it leaves an installation that believes it has rotated and has not.
|
|
195
|
+
throw new ConfigError([
|
|
196
|
+
'NACRE_JWT_PREVIOUS_KEY_REF names the same key as NACRE_JWT_PRIVATE_KEY_REF. ' +
|
|
197
|
+
'That is not a rotation: it accepts one key twice. Point ' +
|
|
198
|
+
'NACRE_JWT_PRIVATE_KEY_REF at the new key and NACRE_JWT_PREVIOUS_KEY_REF ' +
|
|
199
|
+
'at the one it replaces.',
|
|
200
|
+
]);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
signing: current.private,
|
|
204
|
+
verification: current.public,
|
|
205
|
+
alsoAccept: previous === undefined ? [] : [previous.public],
|
|
206
|
+
algorithm: 'EdDSA',
|
|
207
|
+
keyId: fingerprint(der),
|
|
208
|
+
jwks: [
|
|
209
|
+
publicJwk(current.public),
|
|
210
|
+
...(previous === undefined ? [] : [publicJwk(previous.public)]),
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// A symmetric secret is still supported and is still the default. It is what
|
|
215
|
+
// a laptop and a Compose file want, and refusing it would make the asymmetric
|
|
216
|
+
// path mandatory for a product whose first run is `docker compose up`.
|
|
23
217
|
if (secret === undefined || secret.length < 32) {
|
|
24
218
|
throw new ConfigError([
|
|
25
219
|
'NACRE_JWT_SECRET is not set, or is shorter than 32 bytes. ' +
|
|
26
|
-
'
|
|
27
|
-
'
|
|
220
|
+
'Set it, or point NACRE_JWT_PRIVATE_KEY_REF at an Ed25519 private key. ' +
|
|
221
|
+
'There is no default: a signing key in the source is one anybody reading ' +
|
|
222
|
+
'the source can forge tokens with.',
|
|
28
223
|
]);
|
|
29
224
|
}
|
|
30
225
|
const previous = env.NACRE_JWT_SECRET_PREVIOUS;
|
|
31
226
|
if (previous === undefined || previous === '') {
|
|
32
|
-
|
|
227
|
+
const key = new TextEncoder().encode(secret);
|
|
228
|
+
return { signing: key, verification: key, alsoAccept: [], algorithm: 'HS256' };
|
|
33
229
|
}
|
|
34
230
|
if (previous.length < 32) {
|
|
35
231
|
throw new ConfigError([
|
|
@@ -48,9 +244,127 @@ export function loadJwtKeys(env = process.env) {
|
|
|
48
244
|
'new key and NACRE_JWT_SECRET_PREVIOUS to the one it replaces.',
|
|
49
245
|
]);
|
|
50
246
|
}
|
|
247
|
+
const key = new TextEncoder().encode(secret);
|
|
51
248
|
return {
|
|
52
|
-
|
|
249
|
+
signing: key,
|
|
250
|
+
verification: key,
|
|
53
251
|
alsoAccept: [new TextEncoder().encode(previous)],
|
|
252
|
+
algorithm: 'HS256',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Verification-only key material, for a process that checks tokens and never
|
|
257
|
+
* signs one — the MCP transport, which is a resource server.
|
|
258
|
+
*
|
|
259
|
+
* Three ways to name the key, exactly one of them at a time:
|
|
260
|
+
*
|
|
261
|
+
* - `NACRE_JWT_PUBLIC_KEY_REF` — the Ed25519 **public** key, and nothing else.
|
|
262
|
+
* This is the one that makes the asymmetric mode worth what it claims: the
|
|
263
|
+
* verifier holds no secret and no private key, so an attacker who reads its
|
|
264
|
+
* environment can check tokens and cannot mint them. `NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF`
|
|
265
|
+
* is its rotation overlap, mirroring the private side.
|
|
266
|
+
* - `NACRE_JWT_PRIVATE_KEY_REF` — the private key, from which the public half is
|
|
267
|
+
* derived. Accepted so a process co-located with the signer, or one that
|
|
268
|
+
* simply has the private key, still verifies; it just does not get the
|
|
269
|
+
* separation the public-only path does.
|
|
270
|
+
* - `NACRE_JWT_SECRET` — the shared secret. Verifying and signing are the same
|
|
271
|
+
* key here by definition, so there is no separation to be had and the process
|
|
272
|
+
* holds the secret regardless; this exists so a Compose deployment that runs
|
|
273
|
+
* on a shared secret still starts.
|
|
274
|
+
*
|
|
275
|
+
* Setting more than one is refused, for the same reason `loadJwtKeys` refuses
|
|
276
|
+
* two answers to "what signs a token": whichever won, the others would be
|
|
277
|
+
* configured, apparently in use, and ignored.
|
|
278
|
+
*/
|
|
279
|
+
export function loadJwtVerification(env = process.env) {
|
|
280
|
+
const secret = env.NACRE_JWT_SECRET;
|
|
281
|
+
const publicRef = env.NACRE_JWT_PUBLIC_KEY_REF;
|
|
282
|
+
const privateRef = env.NACRE_JWT_PRIVATE_KEY_REF;
|
|
283
|
+
const named = [
|
|
284
|
+
secret !== undefined && secret !== '' ? 'NACRE_JWT_SECRET' : undefined,
|
|
285
|
+
publicRef !== undefined && publicRef !== '' ? 'NACRE_JWT_PUBLIC_KEY_REF' : undefined,
|
|
286
|
+
privateRef !== undefined && privateRef !== '' ? 'NACRE_JWT_PRIVATE_KEY_REF' : undefined,
|
|
287
|
+
].filter((v) => v !== undefined);
|
|
288
|
+
if (named.length > 1) {
|
|
289
|
+
throw new ConfigError([
|
|
290
|
+
`${named.join(' and ')} are set together. They are ${named.length} answers to ` +
|
|
291
|
+
'"what verifies a token" and there is no order of precedence worth ' +
|
|
292
|
+
'inventing. A resource server needs exactly one: the public key ' +
|
|
293
|
+
'(NACRE_JWT_PUBLIC_KEY_REF) if the deployment signs with Ed25519, or ' +
|
|
294
|
+
'NACRE_JWT_SECRET if it signs with a shared secret.',
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
const sameSpki = (a, b) => a
|
|
298
|
+
.export({ type: 'spki', format: 'der' })
|
|
299
|
+
.equals(b.export({ type: 'spki', format: 'der' }));
|
|
300
|
+
if (publicRef !== undefined && publicRef !== '') {
|
|
301
|
+
const current = loadEd25519Public(publicRef, 'NACRE_JWT_PUBLIC_KEY_REF');
|
|
302
|
+
const prevRef = env.NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF;
|
|
303
|
+
const previous = prevRef === undefined || prevRef === ''
|
|
304
|
+
? undefined
|
|
305
|
+
: loadEd25519Public(prevRef, 'NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF');
|
|
306
|
+
if (previous !== undefined && sameSpki(current, previous)) {
|
|
307
|
+
throw new ConfigError([
|
|
308
|
+
'NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF names the same key as ' +
|
|
309
|
+
'NACRE_JWT_PUBLIC_KEY_REF. That is not a rotation: it accepts one key ' +
|
|
310
|
+
'twice. Point NACRE_JWT_PUBLIC_KEY_REF at the new public key and ' +
|
|
311
|
+
'NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF at the one it replaces.',
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
verification: current,
|
|
316
|
+
alsoAccept: previous === undefined ? [] : [previous],
|
|
317
|
+
algorithm: 'EdDSA',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (privateRef !== undefined && privateRef !== '') {
|
|
321
|
+
const current = loadEd25519(privateRef, 'NACRE_JWT_PRIVATE_KEY_REF');
|
|
322
|
+
const prevRef = env.NACRE_JWT_PREVIOUS_KEY_REF;
|
|
323
|
+
const previous = prevRef === undefined || prevRef === ''
|
|
324
|
+
? undefined
|
|
325
|
+
: loadEd25519(prevRef, 'NACRE_JWT_PREVIOUS_KEY_REF');
|
|
326
|
+
if (previous !== undefined && sameSpki(current.public, previous.public)) {
|
|
327
|
+
throw new ConfigError([
|
|
328
|
+
'NACRE_JWT_PREVIOUS_KEY_REF names the same key as NACRE_JWT_PRIVATE_KEY_REF. ' +
|
|
329
|
+
'That is not a rotation: it accepts one key twice.',
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
verification: current.public,
|
|
334
|
+
alsoAccept: previous === undefined ? [] : [previous.public],
|
|
335
|
+
algorithm: 'EdDSA',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (secret === undefined || secret.length < 32) {
|
|
339
|
+
throw new ConfigError([
|
|
340
|
+
'no verification key is set, or NACRE_JWT_SECRET is shorter than 32 bytes. ' +
|
|
341
|
+
'A resource server needs one of NACRE_JWT_PUBLIC_KEY_REF (Ed25519 public ' +
|
|
342
|
+
'key), NACRE_JWT_PRIVATE_KEY_REF, or NACRE_JWT_SECRET — matching how the ' +
|
|
343
|
+
'issuer signs.',
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
const previousSecret = env.NACRE_JWT_SECRET_PREVIOUS;
|
|
347
|
+
if (previousSecret !== undefined && previousSecret !== '') {
|
|
348
|
+
if (previousSecret.length < 32) {
|
|
349
|
+
throw new ConfigError([
|
|
350
|
+
'NACRE_JWT_SECRET_PREVIOUS is set but shorter than 32 bytes. It holds the ' +
|
|
351
|
+
'key being rotated out and is held to the same floor as the one replacing it.',
|
|
352
|
+
]);
|
|
353
|
+
}
|
|
354
|
+
if (previousSecret === secret) {
|
|
355
|
+
throw new ConfigError([
|
|
356
|
+
'NACRE_JWT_SECRET_PREVIOUS is the same value as NACRE_JWT_SECRET. That is ' +
|
|
357
|
+
'not a rotation: it accepts one key twice.',
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const key = new TextEncoder().encode(secret);
|
|
362
|
+
return {
|
|
363
|
+
verification: key,
|
|
364
|
+
alsoAccept: previousSecret === undefined || previousSecret === ''
|
|
365
|
+
? []
|
|
366
|
+
: [new TextEncoder().encode(previousSecret)],
|
|
367
|
+
algorithm: 'HS256',
|
|
54
368
|
};
|
|
55
369
|
}
|
|
56
370
|
export class ConfigError extends Error {
|
|
@@ -238,6 +552,14 @@ export function loadConfig(env = process.env) {
|
|
|
238
552
|
// this is the one tunable here that trades latency for quality directly,
|
|
239
553
|
// and there is a value at which a search stops answering in time.
|
|
240
554
|
rerankCandidates: r.number('NACRE_RERANK_CANDIDATES', 50, { min: 1, max: 500 }),
|
|
555
|
+
// Comma-separated, trimmed, empties dropped. A module that cannot be
|
|
556
|
+
// imported fails startup rather than warning: naming one is a statement
|
|
557
|
+
// that the deployment is paying for what it does, and coming up without it
|
|
558
|
+
// would silently be a different product.
|
|
559
|
+
modules: (env.NACRE_MODULES ?? '')
|
|
560
|
+
.split(',')
|
|
561
|
+
.map((m) => m.trim())
|
|
562
|
+
.filter((m) => m !== ''),
|
|
241
563
|
jwtIssuer: r.required('NACRE_JWT_ISSUER'),
|
|
242
564
|
jwtAudience: r.required('NACRE_JWT_AUDIENCE'),
|
|
243
565
|
accessTokenTtl: r.number('NACRE_ACCESS_TOKEN_TTL', 900, { min: 60, max: 86_400 }),
|
|
@@ -246,7 +568,6 @@ export function loadConfig(env = process.env) {
|
|
|
246
568
|
// choice nobody makes deliberately.
|
|
247
569
|
refreshTokenTtl: r.number('NACRE_REFRESH_TOKEN_TTL', 2_592_000, { min: 300, max: 31_536_000 }),
|
|
248
570
|
aclCacheTtl: r.number('NACRE_ACL_CACHE_TTL', 60, { min: 0, max: 3600 }),
|
|
249
|
-
aclPropagationSla: r.number('NACRE_ACL_PROPAGATION_SLA', 60, { min: 1, max: 3600 }),
|
|
250
571
|
gcGrace: r.number('NACRE_GC_GRACE', 3600, { min: 0, max: 2_592_000 }),
|
|
251
572
|
// 15 minutes: long enough for a large PDF through parse, chunk, embed, and
|
|
252
573
|
// upsert, short enough that a drained node's documents are not stuck for an
|
|
@@ -254,7 +575,6 @@ export function loadConfig(env = process.env) {
|
|
|
254
575
|
// document the instant it is claimed, which is a loop, not a setting.
|
|
255
576
|
indexLease: r.number('NACRE_INDEX_LEASE', 900, { min: 60, max: 86_400 }),
|
|
256
577
|
indexMaxAttempts: r.number('NACRE_INDEX_MAX_ATTEMPTS', 5, { min: 1, max: 100 }),
|
|
257
|
-
aclTagHashBytes: r.number('NACRE_ACL_TAG_HASH_BYTES', 8, { min: 4, max: 32 }),
|
|
258
578
|
rateSearchPerMin: r.number('NACRE_RATE_SEARCH_PER_MIN', 60, { min: 1 }),
|
|
259
579
|
rateIngestPerHour: r.number('NACRE_RATE_INGEST_PER_HOUR', 600, { min: 1 }),
|
|
260
580
|
// Ten attempts per quarter hour, counted per email address. Low enough that
|
|
@@ -313,6 +633,23 @@ export function loadConfig(env = process.env) {
|
|
|
313
633
|
// most likely to be found wrong. Setting it high costs disk: each retained
|
|
314
634
|
// collection is a full copy of the organization's vectors.
|
|
315
635
|
collectionRetentionDays: r.number('NACRE_COLLECTION_RETENTION_DAYS', 7, { min: 1 }),
|
|
636
|
+
// The floor a reindex's recall check must reach before the layer switches.
|
|
637
|
+
//
|
|
638
|
+
// A whole-number percentage rather than a fraction, because this reader
|
|
639
|
+
// takes integers and `0.8` typed into an environment file is a value two
|
|
640
|
+
// different parsers would disagree about. Divided here so everything
|
|
641
|
+
// downstream works in the [0, 1] the arithmetic produces.
|
|
642
|
+
//
|
|
643
|
+
// The default is 80 and it gates nothing on its own: a layer with no
|
|
644
|
+
// reference query set has no check at all, so this applies to deployments
|
|
645
|
+
// that went and wrote one — which is a deployment asking for a gate.
|
|
646
|
+
//
|
|
647
|
+
// 0 is allowed and means measure without blocking. That is arithmetic
|
|
648
|
+
// rather than a special case for "disabled": every recall is at least 0, so
|
|
649
|
+
// the comparison passes and the number is still recorded. `min: 1` would be
|
|
650
|
+
// wrong here for exactly the reason it is right on the retention window
|
|
651
|
+
// above — there the low value destroys something, here it destroys nothing.
|
|
652
|
+
reindexMinRecall: r.number('NACRE_REINDEX_MIN_RECALL', 80, { min: 0, max: 100 }) / 100,
|
|
316
653
|
// How long a `source_url` outlives the permission check that minted it.
|
|
317
654
|
//
|
|
318
655
|
// A presigned URL is a bearer capability: whoever holds it fetches that
|
|
@@ -351,35 +688,11 @@ export function loadConfig(env = process.env) {
|
|
|
351
688
|
'so accepting this would give you a single-collection deployment that ' +
|
|
352
689
|
'believes it is isolated. Use `collection`, which is the default.');
|
|
353
690
|
}
|
|
354
|
-
if (config.aclTagHashBytes !== 8) {
|
|
355
|
-
r.problems.push('NACRE_ACL_TAG_HASH_BYTES is not implemented: the tag width is fixed at 8 ' +
|
|
356
|
-
'bytes in the code that writes and matches tags. Setting it changes the ' +
|
|
357
|
-
'collision probability you think you have and nothing else. Leave it at 8.');
|
|
358
|
-
}
|
|
359
691
|
if (config.refreshTokenTtl <= config.accessTokenTtl) {
|
|
360
692
|
r.problems.push('NACRE_REFRESH_TOKEN_TTL is not longer than NACRE_ACCESS_TOKEN_TTL. A refresh ' +
|
|
361
693
|
'token that expires no later than the access token it renews cannot renew ' +
|
|
362
694
|
'anything, so every session would end at the first refresh.');
|
|
363
695
|
}
|
|
364
|
-
if (config.aclCacheTtl > config.aclPropagationSla) {
|
|
365
|
-
// Kept, and the reason it is kept has changed.
|
|
366
|
-
//
|
|
367
|
-
// It used to say a longer TTL would serve a revoked grant past the SLA.
|
|
368
|
-
// That is not true of the cache that now runs: the key carries
|
|
369
|
-
// `organizations.groups_version`, which triggers bump on every change to
|
|
370
|
-
// groups, group_members and grants, so a revoked grant is never served —
|
|
371
|
-
// the next request composes a different key. The TTL bounds memory.
|
|
372
|
-
//
|
|
373
|
-
// What it still catches is an operator who believes otherwise. Setting this
|
|
374
|
-
// above the SLA is what someone does when they have read it as "how long a
|
|
375
|
-
// stale permission may live", and a deployment configured on that belief
|
|
376
|
-
// has a misunderstanding worth interrupting at boot rather than a setting
|
|
377
|
-
// worth honouring.
|
|
378
|
-
r.problems.push(`NACRE_ACL_CACHE_TTL (${config.aclCacheTtl}) is longer than ` +
|
|
379
|
-
`NACRE_ACL_PROPAGATION_SLA (${config.aclPropagationSla}). The cache is keyed ` +
|
|
380
|
-
'on the permission epoch, so this does not delay a revocation — but a value ' +
|
|
381
|
-
'above the SLA usually means it was read as though it did.');
|
|
382
|
-
}
|
|
383
696
|
if (config.env === 'production' && config.canonicalUrl.startsWith('http://')) {
|
|
384
697
|
// The canonical URL is the OAuth issuer and goes into every token ever
|
|
385
698
|
// issued. Over plaintext it is also the thing an attacker rewrites.
|