@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.
Files changed (59) hide show
  1. package/dist/authz/index.d.ts +0 -1
  2. package/dist/authz/index.d.ts.map +1 -1
  3. package/dist/authz/index.js +0 -1
  4. package/dist/authz/index.js.map +1 -1
  5. package/dist/config.d.ts +110 -4
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +347 -34
  8. package/dist/config.js.map +1 -1
  9. package/dist/db/migrate.d.ts +45 -0
  10. package/dist/db/migrate.d.ts.map +1 -1
  11. package/dist/db/migrate.js +61 -0
  12. package/dist/db/migrate.js.map +1 -1
  13. package/dist/extensions.d.ts +298 -0
  14. package/dist/extensions.d.ts.map +1 -0
  15. package/dist/extensions.js +246 -0
  16. package/dist/extensions.js.map +1 -0
  17. package/dist/index.d.ts +9 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/metrics.d.ts +10 -7
  22. package/dist/metrics.d.ts.map +1 -1
  23. package/dist/metrics.js +25 -7
  24. package/dist/metrics.js.map +1 -1
  25. package/dist/migrations/0015_reference_queries.sql +97 -0
  26. package/dist/migrations/0016_drop_acl_tag_cache.sql +62 -0
  27. package/dist/migrations/0017_drop_workspace_admin_role.sql +30 -0
  28. package/dist/migrations/0018_group_members_nulls_not_distinct.sql +46 -0
  29. package/dist/migrations/0019_embedding_provider_write_guard.sql +58 -0
  30. package/dist/migrations/0020_document_content_type.sql +19 -0
  31. package/dist/multipart.d.ts +55 -0
  32. package/dist/multipart.d.ts.map +1 -0
  33. package/dist/multipart.js +158 -0
  34. package/dist/multipart.js.map +1 -0
  35. package/dist/oauth.d.ts +10 -0
  36. package/dist/oauth.d.ts.map +1 -1
  37. package/dist/oauth.js +10 -0
  38. package/dist/oauth.js.map +1 -1
  39. package/dist/observability.d.ts.map +1 -1
  40. package/dist/observability.js +24 -46
  41. package/dist/observability.js.map +1 -1
  42. package/dist/reindex.d.ts +44 -0
  43. package/dist/reindex.d.ts.map +1 -1
  44. package/dist/reindex.js +49 -0
  45. package/dist/reindex.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/vector/query.d.ts +7 -9
  48. package/dist/vector/query.d.ts.map +1 -1
  49. package/dist/vector/query.js +7 -6
  50. package/dist/vector/query.js.map +1 -1
  51. package/dist/vector/search.d.ts +49 -1
  52. package/dist/vector/search.d.ts.map +1 -1
  53. package/dist/vector/search.js +83 -1
  54. package/dist/vector/search.js.map +1 -1
  55. package/package.json +1 -1
  56. package/dist/authz/tags.d.ts +0 -40
  57. package/dist/authz/tags.d.ts.map +0 -1
  58. package/dist/authz/tags.js +0 -48
  59. package/dist/authz/tags.js.map +0 -1
@@ -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,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACpE,OAAO,EAAE,yBAAyB,EAAE,WAAW,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACnG,YAAY,EAAE,UAAU,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA"}
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"}
@@ -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
@@ -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,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AACpE,OAAO,EAAE,yBAAyB,EAAE,WAAW,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA"}
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 key: Uint8Array;
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[]);
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"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"}
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
- 'Asymmetric keys through NACRE_JWT_PRIVATE_KEY_REF are not implemented yet; ' +
27
- 'until they are, this is required and there is no default.',
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
- return { key: new TextEncoder().encode(secret), alsoAccept: [] };
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
- key: new TextEncoder().encode(secret),
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.