@nacre.work/core 0.0.0 → 0.2.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/dist/audit.d.ts +41 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +18 -0
- package/dist/audit.js.map +1 -0
- package/dist/authz/cache.d.ts +20 -0
- package/dist/authz/cache.d.ts.map +1 -1
- package/dist/authz/cache.js +29 -0
- package/dist/authz/cache.js.map +1 -1
- package/dist/authz/filter.d.ts +54 -3
- package/dist/authz/filter.d.ts.map +1 -1
- package/dist/authz/filter.js +45 -9
- package/dist/authz/filter.js.map +1 -1
- package/dist/authz/index.d.ts +3 -3
- package/dist/authz/index.d.ts.map +1 -1
- package/dist/authz/index.js +2 -2
- package/dist/authz/index.js.map +1 -1
- package/dist/config.d.ts +55 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +245 -7
- package/dist/config.js.map +1 -1
- package/dist/db/client.d.ts +39 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +82 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +49 -0
- package/dist/db/migrate.js.map +1 -1
- package/dist/index.d.ts +21 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +84 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/logging.d.ts +46 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +110 -0
- package/dist/logging.js.map +1 -0
- package/dist/metadata.d.ts +50 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +127 -0
- package/dist/metadata.js.map +1 -0
- package/dist/metrics.d.ts +19 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +84 -2
- package/dist/metrics.js.map +1 -1
- package/dist/migrations/0008_authenticating_lookup.sql +124 -0
- package/dist/migrations/0009_refresh_tokens.sql +73 -0
- package/dist/migrations/0010_definer_search_path.sql +22 -0
- package/dist/migrations/0011_sweep_lease.sql +36 -0
- package/dist/migrations/0012_prune.sql +118 -0
- package/dist/migrations/0013_reindex.sql +65 -0
- package/dist/migrations/0014_retired_collections.sql +68 -0
- package/dist/oauth.d.ts +45 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +53 -0
- package/dist/oauth.js.map +1 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +100 -13
- package/dist/observability.js.map +1 -1
- package/dist/passwords.d.ts +37 -0
- package/dist/passwords.d.ts.map +1 -0
- package/dist/passwords.js +234 -0
- package/dist/passwords.js.map +1 -0
- package/dist/redis.d.ts +46 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +234 -0
- package/dist/redis.js.map +1 -0
- package/dist/reindex.d.ts +87 -0
- package/dist/reindex.d.ts.map +1 -0
- package/dist/reindex.js +97 -0
- package/dist/reindex.js.map +1 -0
- package/dist/s3.d.ts +111 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +242 -0
- package/dist/s3.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/vector/query.d.ts +49 -4
- package/dist/vector/query.d.ts.map +1 -1
- package/dist/vector/query.js +43 -10
- package/dist/vector/query.js.map +1 -1
- package/dist/vector/search.d.ts +175 -4
- package/dist/vector/search.d.ts.map +1 -1
- package/dist/vector/search.js +320 -7
- package/dist/vector/search.js.map +1 -1
- package/package.json +26 -8
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document metadata: what a caller may write, and what they may narrow a search
|
|
3
|
+
* by.
|
|
4
|
+
*
|
|
5
|
+
* One module for both ends on purpose. Ingest and search accept the same shape
|
|
6
|
+
* from the same untrusted place, and two validators would drift — the ingest
|
|
7
|
+
* side would grow a type the search side could not express, or the search side
|
|
8
|
+
* would accept a key ingest had refused, and a caller would be filtering on a
|
|
9
|
+
* field that can never exist.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is a bound. Metadata is written into the vector payload of
|
|
12
|
+
* *every chunk of a document*, so a key is stored once per point and a
|
|
13
|
+
* thousand-chunk document multiplies whatever arrives here by a thousand. The
|
|
14
|
+
* limits are deliberately small; a knowledge index wants `source`, `team`,
|
|
15
|
+
* `updated_at`, not a document's worth of JSON a second time.
|
|
16
|
+
*/
|
|
17
|
+
/** Keys per document. */
|
|
18
|
+
export const MAX_METADATA_KEYS = 32;
|
|
19
|
+
export const MAX_METADATA_KEY_LENGTH = 64;
|
|
20
|
+
/** A string value, and every string inside a list value. */
|
|
21
|
+
export const MAX_METADATA_VALUE_LENGTH = 256;
|
|
22
|
+
/** Values in one list. */
|
|
23
|
+
export const MAX_METADATA_LIST = 32;
|
|
24
|
+
/**
|
|
25
|
+
* Keys are `[a-z0-9_]`, lower case, and cannot start with a digit.
|
|
26
|
+
*
|
|
27
|
+
* Narrow because the key becomes a Qdrant payload field name, and Qdrant reads
|
|
28
|
+
* `.` as nested access and `[]` as array indexing — a key containing either
|
|
29
|
+
* would filter on something other than what the caller wrote. Refusing the
|
|
30
|
+
* characters is one line; discovering that `a.b` and `a` `{b}` are the same
|
|
31
|
+
* field is a support ticket.
|
|
32
|
+
*/
|
|
33
|
+
const KEY = /^[a-z_][a-z0-9_]*$/;
|
|
34
|
+
export class MetadataError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'MetadataError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function checkScalar(key, value) {
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
if (value.length > MAX_METADATA_VALUE_LENGTH) {
|
|
43
|
+
throw new MetadataError(`metadata value for '${key}' is over ${MAX_METADATA_VALUE_LENGTH} characters`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === 'number') {
|
|
48
|
+
// NaN and the infinities have no JSON representation, so they arrive here
|
|
49
|
+
// only from a caller building the body in code — and they would be written
|
|
50
|
+
// as `null`, which is a value nothing can filter for.
|
|
51
|
+
if (!Number.isFinite(value)) {
|
|
52
|
+
throw new MetadataError(`metadata value for '${key}' is not a finite number`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === 'boolean')
|
|
57
|
+
return value;
|
|
58
|
+
throw new MetadataError(`metadata value for '${key}' must be a string, number, boolean, or a list of those. ` +
|
|
59
|
+
'Nested objects are not accepted: the filter surface would have to grow a path ' +
|
|
60
|
+
'syntax, and a path is a way to reach a field the caller did not name.');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Validate and normalise metadata arriving from a request.
|
|
64
|
+
*
|
|
65
|
+
* Raises rather than dropping what it does not like. A silently discarded key
|
|
66
|
+
* is a document a caller believes is tagged and a filter that will never match
|
|
67
|
+
* it — the same class of failure as a search parameter read by nothing, and the
|
|
68
|
+
* reason `filters` answered 400 rather than being ignored.
|
|
69
|
+
*/
|
|
70
|
+
export function parseMetadata(raw, what = 'metadata') {
|
|
71
|
+
if (raw === undefined || raw === null)
|
|
72
|
+
return {};
|
|
73
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
74
|
+
throw new MetadataError(`'${what}' must be an object`);
|
|
75
|
+
}
|
|
76
|
+
const entries = Object.entries(raw);
|
|
77
|
+
if (entries.length > MAX_METADATA_KEYS) {
|
|
78
|
+
throw new MetadataError(`'${what}' has more than ${MAX_METADATA_KEYS} keys`);
|
|
79
|
+
}
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [key, value] of entries) {
|
|
82
|
+
if (key.length > MAX_METADATA_KEY_LENGTH) {
|
|
83
|
+
throw new MetadataError(`metadata key '${key.slice(0, 20)}…' is over ${MAX_METADATA_KEY_LENGTH} characters`);
|
|
84
|
+
}
|
|
85
|
+
if (!KEY.test(key)) {
|
|
86
|
+
throw new MetadataError(`metadata key '${key}' must match ${String(KEY)} — lower case letters, digits and ` +
|
|
87
|
+
'underscores, not starting with a digit');
|
|
88
|
+
}
|
|
89
|
+
// `undefined` survives an object literal in TypeScript but not JSON, so it
|
|
90
|
+
// reaches here only from a caller assembling the body in code. Dropping it
|
|
91
|
+
// is the one omission that is not a surprise: the key is absent either way.
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
if (value.length > MAX_METADATA_LIST) {
|
|
96
|
+
throw new MetadataError(`metadata value for '${key}' has more than ${MAX_METADATA_LIST} entries`);
|
|
97
|
+
}
|
|
98
|
+
out[key] = value.map((v) => checkScalar(key, v));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
out[key] = checkScalar(key, value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The same bounds, plus the one rule that belongs only to filtering.
|
|
108
|
+
*
|
|
109
|
+
* An empty list is a legitimate *value* — a document may be tagged with no
|
|
110
|
+
* teams — and an impossible *restriction*: "team is one of nothing" matches
|
|
111
|
+
* nothing at all. `buildFilter` refuses to encode it, deliberately, so that
|
|
112
|
+
* what an impossible restriction means stays the caller's decision rather than
|
|
113
|
+
* the query builder's. This is where that decision is made for a request: a 400
|
|
114
|
+
* naming the key, because a client that built a list and put nothing in it has
|
|
115
|
+
* a bug, and answering with an empty result would hide it.
|
|
116
|
+
*/
|
|
117
|
+
export function parseFilters(raw) {
|
|
118
|
+
const parsed = parseMetadata(raw, 'filters');
|
|
119
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
120
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
121
|
+
throw new MetadataError(`filter on '${key}' has no values, so it can match nothing. Drop the key to ` +
|
|
122
|
+
'search without that restriction.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=metadata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.js","sourceRoot":"","sources":["../metadata.ts"],"names":[],"mappings":"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"}
|
package/dist/metrics.d.ts
CHANGED
|
@@ -52,7 +52,22 @@ export declare class Registry {
|
|
|
52
52
|
* counters — document counts, the propagation lag, pending tombstones.
|
|
53
53
|
*/
|
|
54
54
|
collect(fn: () => Promise<void>): void;
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* How long a collected value is reused.
|
|
57
|
+
*
|
|
58
|
+
* `/metrics` is unauthenticated by default and the collectors are database
|
|
59
|
+
* queries — one per organization. Without this, anyone who can reach the port
|
|
60
|
+
* decides how often the API queries every tenant's `documents` table, on the
|
|
61
|
+
* same pool the request path uses. A scrape loop is a denial of service that
|
|
62
|
+
* looks like monitoring.
|
|
63
|
+
*
|
|
64
|
+
* Ten seconds is shorter than any sensible scrape interval, so a real
|
|
65
|
+
* Prometheus never sees a cached value and the numbers are as live as they
|
|
66
|
+
* were. It only collapses the excess: a second scraper, a human with `watch
|
|
67
|
+
* curl`, or a flood.
|
|
68
|
+
*/
|
|
69
|
+
static readonly COLLECT_TTL_MS = 10000;
|
|
70
|
+
render(now?: number): Promise<string>;
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
73
|
* The metrics docs/config.md requires.
|
|
@@ -67,10 +82,13 @@ export declare function createMetrics(registry: Registry): {
|
|
|
67
82
|
searchDuration: Histogram;
|
|
68
83
|
searchResults: Counter;
|
|
69
84
|
aclDenials: Counter;
|
|
85
|
+
authFailures: Counter;
|
|
70
86
|
aclPropagationLag: Gauge;
|
|
71
87
|
ingestDuration: Histogram;
|
|
72
88
|
documents: Gauge;
|
|
89
|
+
reindexProgress: Gauge;
|
|
73
90
|
tombstonesPending: Gauge;
|
|
91
|
+
collectionsRetired: Gauge;
|
|
74
92
|
};
|
|
75
93
|
export type Metrics = ReturnType<typeof createMetrics>;
|
|
76
94
|
export {};
|
package/dist/metrics.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;AAgBrD,UAAU,MAAM;IACd,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,WAAW,CAAA;IAChD,MAAM,IAAI,MAAM,EAAE,CAAA;CACnB;AAED,qBAAa,OAAQ,YAAW,MAAM;;IAKlC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IALvB,QAAQ,CAAC,IAAI,EAAG,SAAS,CAAS;gBAIvB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM;IAKvB,GAAG,CAAC,MAAM,GAAE,MAAW,EAAE,EAAE,SAAI,GAAG,IAAI;IAOtC,MAAM,IAAI,MAAM,EAAE;CAOnB;AAED,qBAAa,KAAM,YAAW,MAAM;;IAKhC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IALvB,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAS;gBAIrB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM;IAKvB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI;IAI7C,KAAK,IAAI,IAAI;IAIb,MAAM,IAAI,MAAM,EAAE;CAInB;AAID,qBAAa,SAAU,YAAW,MAAM;;IAKpC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE;IANrC,QAAQ,CAAC,IAAI,EAAG,WAAW,CAAS;gBAIzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,SAAS,MAAM,EAAoB;IAKvD,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI;IAgBnD,2EAA2E;IACrE,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAS/D,MAAM,IAAI,MAAM,EAAE;CAmBnB;AAED,qBAAa,QAAQ;;IAInB,QAAQ,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC;IAQxC;;;OAGG;IACH,OAAO,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;
|
|
1
|
+
{"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;AAgBrD,UAAU,MAAM;IACd,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,WAAW,CAAA;IAChD,MAAM,IAAI,MAAM,EAAE,CAAA;CACnB;AAED,qBAAa,OAAQ,YAAW,MAAM;;IAKlC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IALvB,QAAQ,CAAC,IAAI,EAAG,SAAS,CAAS;gBAIvB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM;IAKvB,GAAG,CAAC,MAAM,GAAE,MAAW,EAAE,EAAE,SAAI,GAAG,IAAI;IAOtC,MAAM,IAAI,MAAM,EAAE;CAOnB;AAED,qBAAa,KAAM,YAAW,MAAM;;IAKhC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IALvB,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAS;gBAIrB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM;IAKvB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI;IAI7C,KAAK,IAAI,IAAI;IAIb,MAAM,IAAI,MAAM,EAAE;CAInB;AAID,qBAAa,SAAU,YAAW,MAAM;;IAKpC,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE;IANrC,QAAQ,CAAC,IAAI,EAAG,WAAW,CAAS;gBAIzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,SAAS,MAAM,EAAoB;IAKvD,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI;IAgBnD,2EAA2E;IACrE,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAS/D,MAAM,IAAI,MAAM,EAAE;CAmBnB;AAED,qBAAa,QAAQ;;IAInB,QAAQ,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,CAAC;IAQxC;;;OAGG;IACH,OAAO,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAItC;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,QAAQ,CAAC,cAAc,SAAS;IAwCjC,MAAM,CAAC,GAAG,GAAE,MAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;CAWxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ;;;;;;;;;;;EA4E/C;AAED,MAAM,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAA"}
|
package/dist/metrics.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* to keep patched. If the needs here outgrow histograms and gauges, replace it
|
|
7
7
|
* with prom-client rather than growing this file.
|
|
8
8
|
*/
|
|
9
|
+
var _a;
|
|
9
10
|
const NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
10
11
|
function escapeLabel(value) {
|
|
11
12
|
return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
|
|
@@ -141,10 +142,59 @@ export class Registry {
|
|
|
141
142
|
collect(fn) {
|
|
142
143
|
this.#collectors.push(fn);
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
+
/**
|
|
146
|
+
* How long a collected value is reused.
|
|
147
|
+
*
|
|
148
|
+
* `/metrics` is unauthenticated by default and the collectors are database
|
|
149
|
+
* queries — one per organization. Without this, anyone who can reach the port
|
|
150
|
+
* decides how often the API queries every tenant's `documents` table, on the
|
|
151
|
+
* same pool the request path uses. A scrape loop is a denial of service that
|
|
152
|
+
* looks like monitoring.
|
|
153
|
+
*
|
|
154
|
+
* Ten seconds is shorter than any sensible scrape interval, so a real
|
|
155
|
+
* Prometheus never sees a cached value and the numbers are as live as they
|
|
156
|
+
* were. It only collapses the excess: a second scraper, a human with `watch
|
|
157
|
+
* curl`, or a flood.
|
|
158
|
+
*/
|
|
159
|
+
static COLLECT_TTL_MS = 10_000;
|
|
160
|
+
/**
|
|
161
|
+
* When the collectors last finished, on the same clock `render` was given.
|
|
162
|
+
*
|
|
163
|
+
* `undefined` and not 0, because "never collected" and "collected at time
|
|
164
|
+
* zero" have to be different: a caller injecting a clock — a test, or
|
|
165
|
+
* anything replaying — would otherwise find a fresh registry believing it had
|
|
166
|
+
* just collected, and the first scrape would return empty gauges.
|
|
167
|
+
*/
|
|
168
|
+
#collectedAt;
|
|
169
|
+
#collecting;
|
|
170
|
+
/**
|
|
171
|
+
* Run the collectors, at most one run at a time and at most one per window.
|
|
172
|
+
*
|
|
173
|
+
* The single-flight part matters as much as the cache: two scrapes arriving
|
|
174
|
+
* together used to start two full sweeps, so concurrency multiplied the cost
|
|
175
|
+
* that the cache alone would bound only in sequence.
|
|
176
|
+
*/
|
|
177
|
+
async #collectOnce(now) {
|
|
178
|
+
if (this.#collecting !== undefined)
|
|
179
|
+
return this.#collecting;
|
|
180
|
+
if (this.#collectedAt !== undefined && now - this.#collectedAt < _a.COLLECT_TTL_MS)
|
|
181
|
+
return;
|
|
145
182
|
// A collector that fails must not blank the whole exposition: the metrics
|
|
146
183
|
// that still work are how you find out which one broke.
|
|
147
|
-
|
|
184
|
+
this.#collecting = Promise.allSettled(this.#collectors.map((fn) => fn())).then(() => {
|
|
185
|
+
// Stamped with the clock this call was given, never with Date.now().
|
|
186
|
+
// Mixing the two means an injected clock is compared against a real one,
|
|
187
|
+
// and the window is then either always open or never.
|
|
188
|
+
//
|
|
189
|
+
// On completion rather than on entry, so a sweep slower than the window
|
|
190
|
+
// does not have the next one queued behind it the instant it finishes.
|
|
191
|
+
this.#collectedAt = now;
|
|
192
|
+
this.#collecting = undefined;
|
|
193
|
+
});
|
|
194
|
+
return this.#collecting;
|
|
195
|
+
}
|
|
196
|
+
async render(now = Date.now()) {
|
|
197
|
+
await this.#collectOnce(now);
|
|
148
198
|
const lines = [];
|
|
149
199
|
for (const metric of this.#metrics) {
|
|
150
200
|
lines.push(`# HELP ${metric.name} ${metric.help}`);
|
|
@@ -154,6 +204,7 @@ export class Registry {
|
|
|
154
204
|
return `${lines.join('\n')}\n`;
|
|
155
205
|
}
|
|
156
206
|
}
|
|
207
|
+
_a = Registry;
|
|
157
208
|
/**
|
|
158
209
|
* The metrics docs/config.md requires.
|
|
159
210
|
*
|
|
@@ -170,10 +221,41 @@ export function createMetrics(registry) {
|
|
|
170
221
|
])),
|
|
171
222
|
searchResults: registry.register(new Counter('nacre_search_results_total', 'Results returned, by layer')),
|
|
172
223
|
aclDenials: registry.register(new Counter('nacre_acl_denials_total', 'Access denials, by reason')),
|
|
224
|
+
// Labelled by what was presented, never by why it failed. The 401 itself
|
|
225
|
+
// carries one message for every reason so a caller cannot tell an expired
|
|
226
|
+
// token from a forged one; a `reason` label would hand that distinction
|
|
227
|
+
// back through an endpoint that is unauthenticated by default.
|
|
228
|
+
//
|
|
229
|
+
// `kind="jwt"` is the series a key rotation moves, and it is why this
|
|
230
|
+
// exists: there is no dual-key window, so every outstanding access token
|
|
231
|
+
// fails at once and an operator has to watch that drain. `kind="service_key"`
|
|
232
|
+
// staying flat through the same window is the check that the rotation hit
|
|
233
|
+
// only what it was meant to.
|
|
234
|
+
authFailures: registry.register(new Counter('nacre_auth_failures_total', 'Rejected credentials, by the kind presented: missing, jwt, service_key. Never by reason — the 401 is deliberately one answer')),
|
|
173
235
|
aclPropagationLag: registry.register(new Gauge('nacre_acl_propagation_lag_seconds', 'Age in seconds of the oldest document, per organization, whose ACL tags predate the current groups_version. Alert on max() over this. The only external evidence that invariant I4 holds')),
|
|
174
236
|
ingestDuration: registry.register(new Histogram('nacre_ingest_duration_seconds', 'Indexing latency, by stage')),
|
|
175
237
|
documents: registry.register(new Gauge('nacre_documents_total', 'Documents, by organization and status')),
|
|
238
|
+
// Specified in docs/config.md and registered by nothing until there was a
|
|
239
|
+
// reindex to measure.
|
|
240
|
+
//
|
|
241
|
+
// One series per layer that has ever been reindexed, and no series for the
|
|
242
|
+
// rest — a layer nobody has migrated has no progress to report, and
|
|
243
|
+
// inventing a zero for it would mean every layer in the installation
|
|
244
|
+
// permanently reads "reindex started, gone nowhere".
|
|
245
|
+
//
|
|
246
|
+
// With no reindexes at all the registry still emits a bare
|
|
247
|
+
// `nacre_reindex_progress_ratio 0`, because an unlabelled gauge with no
|
|
248
|
+
// values renders that way here. That is the "nothing to report" marker
|
|
249
|
+
// rather than a layer at zero: a real layer always carries a `layer` label.
|
|
250
|
+
reindexProgress: registry.register(new Gauge('nacre_reindex_progress_ratio', 'How far a layer is through a reindex, 0 to 1. Absent when no reindex has run; 0 means one started and has moved nothing')),
|
|
176
251
|
tombstonesPending: registry.register(new Gauge('nacre_tombstones_pending_total', 'Deleted documents whose vectors are not yet purged. Climbing means garbage collection is losing')),
|
|
252
|
+
// A gauge and not a counter, because the question an operator has is "how
|
|
253
|
+
// much disk is a finished migration still holding", not "how many have I
|
|
254
|
+
// reclaimed since this process started". Each one retained is a full copy
|
|
255
|
+
// of an organization's vectors, so a number that stays high is the signal —
|
|
256
|
+
// and a number that never falls means the sweep is not running at all,
|
|
257
|
+
// which is the state this whole table replaced.
|
|
258
|
+
collectionsRetired: registry.register(new Gauge('nacre_collections_retired_total', 'Superseded collections still held for the reindex rollback window, by organization. Each is a full copy of that organization vectors')),
|
|
177
259
|
};
|
|
178
260
|
}
|
|
179
261
|
//# sourceMappingURL=metrics.js.map
|
package/dist/metrics.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metrics.js","sourceRoot":"","sources":["../metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG
|
|
1
|
+
{"version":3,"file":"metrics.js","sourceRoot":"","sources":["../metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;;AAIH,MAAM,IAAI,GAAG,4BAA4B,CAAA;AAEzC,SAAS,WAAW,CAAC,KAAa;IAChC,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;AAChF,CAAC;AAED,SAAS,MAAM,CAAC,IAAY,EAAE,MAAc;IAC1C,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;SACjC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;SAC3B,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;SACtC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IAC9C,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAA;AAClE,CAAC;AASD,MAAM,OAAO,OAAO;IAKP;IACA;IALF,IAAI,GAAG,SAAkB,CAAA;IACzB,OAAO,GAAG,IAAI,GAAG,EAA6C,CAAA;IAEvE,YACW,IAAY,EACZ,IAAY;QADZ,SAAI,GAAJ,IAAI,CAAQ;QACZ,SAAI,GAAJ,IAAI,CAAQ;QAErB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAA;IAC3E,CAAC;IAED,GAAG,CAAC,SAAiB,EAAE,EAAE,EAAE,GAAG,CAAC;QAC7B,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,CAAA;QAC3D,KAAK,CAAC,KAAK,IAAI,EAAE,CAAA;QACjB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;IAC9B,CAAC;IAED,MAAM;QACJ,uEAAuE;QACvE,sEAAsE;QACtE,mEAAmE;QACnE,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,CAAC,CAAA;QACtD,OAAO,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAA;IACjE,CAAC;CACF;AAED,MAAM,OAAO,KAAK;IAKL;IACA;IALF,IAAI,GAAG,OAAgB,CAAA;IACvB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAA;IAE5C,YACW,IAAY,EACZ,IAAY;QADZ,SAAI,GAAJ,IAAI,CAAQ;QACZ,SAAI,GAAJ,IAAI,CAAQ;QAErB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAA;IAC3E,CAAC;IAED,GAAG,CAAC,KAAa,EAAE,SAAiB,EAAE;QACpC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,KAAK,CAAC,CAAA;IACpD,CAAC;IAED,KAAK;QACH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAA;IACtB,CAAC;IAED,MAAM;QACJ,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC;YAAE,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,CAAC,CAAA;QACtD,OAAO,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,CAAC,CAAA;IAC3D,CAAC;CACF;AAED,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;AAEjF,MAAM,OAAO,SAAS;IAKT;IACA;IACA;IANF,IAAI,GAAG,WAAoB,CAAA;IAC3B,OAAO,GAAG,IAAI,GAAG,EAA4E,CAAA;IAEtG,YACW,IAAY,EACZ,IAAY,EACZ,UAA6B,eAAe;QAF5C,SAAI,GAAJ,IAAI,CAAQ;QACZ,SAAI,GAAJ,IAAI,CAAQ;QACZ,YAAO,GAAP,OAAO,CAAqC;QAErD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAA;IAC3E,CAAC;IAED,OAAO,CAAC,OAAe,EAAE,SAAiB,EAAE;QAC1C,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI;YACrC,MAAM;YACN,MAAM,EAAE,IAAI,KAAK,CAAS,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;YACtD,GAAG,EAAE,CAAC;YACN,KAAK,EAAE,CAAC;SACT,CAAA;QACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,IAAI,OAAO,IAAK,IAAI,CAAC,OAAO,CAAC,CAAC,CAAY;gBAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,GAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAY,GAAG,CAAC,CAAA;QAC/F,CAAC;QACD,KAAK,CAAC,GAAG,IAAI,OAAO,CAAA;QACpB,KAAK,CAAC,KAAK,IAAI,CAAC,CAAA;QAChB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;IAC9B,CAAC;IAED,2EAA2E;IAC3E,KAAK,CAAC,IAAI,CAAI,MAAc,EAAE,EAAoB;QAChD,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAA;QACjC,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAA;QACnB,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,OAAO,CAAC,CAAC,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAA;QAC5D,CAAC;IACH,CAAC;IAED,MAAM;QACJ,MAAM,KAAK,GAAa,EAAE,CAAA;QAC1B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;YAC1C,sEAAsE;YACtE,uEAAuE;YACvE,sEAAsE;YACtE,wEAAwE;YACxE,wBAAwB;YACxB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBAC7C,KAAK,CAAC,IAAI,CACR,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,SAAS,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,CAAC,CAAW,EAAE,CAClH,CAAA;YACH,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,SAAS,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,CAAA;YAC9F,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,EAAE,CAAC,CAAA;YACtE,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,CAAA;QAC5E,CAAC;QACD,OAAO,KAAK,CAAA;IACd,CAAC;CACF;AAED,MAAM,OAAO,QAAQ;IACV,QAAQ,GAAa,EAAE,CAAA;IACvB,WAAW,GAA4B,EAAE,CAAA;IAElD,QAAQ,CAAmB,MAAS;QAClC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;YACtD,MAAM,IAAI,KAAK,CAAC,8BAA8B,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;QAC9D,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC1B,OAAO,MAAM,CAAA;IACf,CAAC;IAED;;;OAGG;IACH,OAAO,CAAC,EAAuB;QAC7B,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC3B,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAU,cAAc,GAAG,MAAM,CAAA;IAEvC;;;;;;;OAOG;IACH,YAAY,CAAoB;IAChC,WAAW,CAA2B;IAEtC;;;;;;OAMG;IACH,KAAK,CAAC,YAAY,CAAC,GAAW;QAC5B,IAAI,IAAI,CAAC,WAAW,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,WAAW,CAAA;QAC3D,IAAI,IAAI,CAAC,YAAY,KAAK,SAAS,IAAI,GAAG,GAAG,IAAI,CAAC,YAAY,GAAG,EAAQ,CAAC,cAAc;YAAE,OAAM;QAEhG,0EAA0E;QAC1E,wDAAwD;QACxD,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE;YAClF,qEAAqE;YACrE,yEAAyE;YACzE,sDAAsD;YACtD,EAAE;YACF,wEAAwE;YACxE,uEAAuE;YACvE,IAAI,CAAC,YAAY,GAAG,GAAG,CAAA;YACvB,IAAI,CAAC,WAAW,GAAG,SAAS,CAAA;QAC9B,CAAC,CAAC,CAAA;QAEF,OAAO,IAAI,CAAC,WAAW,CAAA;IACzB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAAc,IAAI,CAAC,GAAG,EAAE;QACnC,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAA;QAE5B,MAAM,KAAK,GAAa,EAAE,CAAA;QAC1B,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnC,KAAK,CAAC,IAAI,CAAC,UAAU,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;YAClD,KAAK,CAAC,IAAI,CAAC,UAAU,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC,CAAA;YAClD,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAA;QAChC,CAAC;QACD,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAA;IAChC,CAAC;;;AAGH;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,QAAkB;IAC9C,OAAO;QACL,cAAc,EAAE,QAAQ,CAAC,QAAQ,CAC/B,IAAI,SAAS,CAAC,+BAA+B,EAAE,4BAA4B,EAAE;YAC3E,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;SAC1C,CAAC,CACH;QACD,aAAa,EAAE,QAAQ,CAAC,QAAQ,CAC9B,IAAI,OAAO,CAAC,4BAA4B,EAAE,4BAA4B,CAAC,CACxE;QACD,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAC3B,IAAI,OAAO,CAAC,yBAAyB,EAAE,2BAA2B,CAAC,CACpE;QACD,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxE,+DAA+D;QAC/D,EAAE;QACF,sEAAsE;QACtE,yEAAyE;QACzE,8EAA8E;QAC9E,0EAA0E;QAC1E,6BAA6B;QAC7B,YAAY,EAAE,QAAQ,CAAC,QAAQ,CAC7B,IAAI,OAAO,CACT,2BAA2B,EAC3B,8HAA8H,CAC/H,CACF;QACD,iBAAiB,EAAE,QAAQ,CAAC,QAAQ,CAClC,IAAI,KAAK,CACP,mCAAmC,EACnC,0LAA0L,CAC3L,CACF;QACD,cAAc,EAAE,QAAQ,CAAC,QAAQ,CAC/B,IAAI,SAAS,CAAC,+BAA+B,EAAE,4BAA4B,CAAC,CAC7E;QACD,SAAS,EAAE,QAAQ,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,uBAAuB,EAAE,uCAAuC,CAAC,CAAC;QACzG,0EAA0E;QAC1E,sBAAsB;QACtB,EAAE;QACF,2EAA2E;QAC3E,oEAAoE;QACpE,qEAAqE;QACrE,qDAAqD;QACrD,EAAE;QACF,2DAA2D;QAC3D,wEAAwE;QACxE,uEAAuE;QACvE,4EAA4E;QAC5E,eAAe,EAAE,QAAQ,CAAC,QAAQ,CAChC,IAAI,KAAK,CACP,8BAA8B,EAC9B,yHAAyH,CAC1H,CACF;QACD,iBAAiB,EAAE,QAAQ,CAAC,QAAQ,CAClC,IAAI,KAAK,CACP,gCAAgC,EAChC,iGAAiG,CAClG,CACF;QACD,0EAA0E;QAC1E,yEAAyE;QACzE,0EAA0E;QAC1E,4EAA4E;QAC5E,uEAAuE;QACvE,gDAAgD;QAChD,kBAAkB,EAAE,QAAQ,CAAC,QAAQ,CACnC,IAAI,KAAK,CACP,iCAAiC,EACjC,sIAAsI,CACvI,CACF;KACF,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
-- Resolving a credential cannot be scoped to an organization, because the
|
|
2
|
+
-- credential is what says which organization it is.
|
|
3
|
+
--
|
|
4
|
+
-- `PostgresServiceKeys.resolve` reads `service_accounts` by key prefix across
|
|
5
|
+
-- every tenant for exactly that reason. It has never worked anywhere the
|
|
6
|
+
-- documentation says to deploy it: `org_isolation` calls
|
|
7
|
+
-- `current_setting('app.current_org')` in its one-argument form, which raises
|
|
8
|
+
-- `unrecognized configuration parameter` rather than returning null, so the
|
|
9
|
+
-- query fails outright on any connection that is subject to row-level
|
|
10
|
+
-- security. It works in development only because development connects as a
|
|
11
|
+
-- superuser — the one configuration `docs/config.md` explicitly forbids:
|
|
12
|
+
--
|
|
13
|
+
-- $ psql "postgres://nacre_app@…" -c "SELECT id, org_id FROM service_accounts
|
|
14
|
+
-- WHERE key_prefix = '…'"
|
|
15
|
+
-- ERROR: unrecognized configuration parameter "app.current_org"
|
|
16
|
+
--
|
|
17
|
+
-- So every service account key stops working the moment an operator follows
|
|
18
|
+
-- the rule about not connecting as a superuser. Nothing failed in CI, because
|
|
19
|
+
-- CI connects as `postgres` too.
|
|
20
|
+
--
|
|
21
|
+
-- ─── the fix, and why it is a policy rather than a bypass ───
|
|
22
|
+
--
|
|
23
|
+
-- The obvious repair is a SECURITY DEFINER function, and it does not work
|
|
24
|
+
-- here: these tables carry FORCE ROW LEVEL SECURITY, which applies to the
|
|
25
|
+
-- owner as well, so a function running as the owner is still filtered. The
|
|
26
|
+
-- alternative — a role with BYPASSRLS — buys a permanent hole in the isolation
|
|
27
|
+
-- model to solve a problem that lasts one query.
|
|
28
|
+
--
|
|
29
|
+
-- Row-level security is the mechanism, so this uses it. A second policy admits
|
|
30
|
+
-- the lookup, and only while `app.authenticating` is set on the connection.
|
|
31
|
+
-- Policies for the same command are OR'd, so the org-scoped policy is
|
|
32
|
+
-- unchanged for every other caller.
|
|
33
|
+
--
|
|
34
|
+
-- The guard on `org_isolation` is what makes that safe. Without it Postgres
|
|
35
|
+
-- may evaluate the org-scoped expression anyway and raise before the second
|
|
36
|
+
-- policy is ever consulted; `CASE` short-circuits, so the raising call is not
|
|
37
|
+
-- reached while authenticating. It is deliberately left raising in every other
|
|
38
|
+
-- case: a query that forgets `withOrg` should fail loudly rather than quietly
|
|
39
|
+
-- return nothing, and "quietly returns nothing" is indistinguishable from a
|
|
40
|
+
-- permission working correctly, which is the hardest kind of bug to see.
|
|
41
|
+
|
|
42
|
+
ALTER POLICY org_isolation ON service_accounts
|
|
43
|
+
USING (
|
|
44
|
+
CASE
|
|
45
|
+
WHEN current_setting('app.authenticating', true) = 'on' THEN false
|
|
46
|
+
ELSE org_id = current_setting('app.current_org')::uuid
|
|
47
|
+
END
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
-- SELECT only. Authentication reads a credential; it never writes one, and a
|
|
51
|
+
-- policy that admitted INSERT or UPDATE here would let the one path that runs
|
|
52
|
+
-- without an organization create rows in any of them.
|
|
53
|
+
CREATE POLICY authenticating ON service_accounts
|
|
54
|
+
FOR SELECT
|
|
55
|
+
USING (current_setting('app.authenticating', true) = 'on');
|
|
56
|
+
|
|
57
|
+
-- `users` gets neither, and that is a decision rather than an omission. Login
|
|
58
|
+
-- resolves the organization from `organizations` — which has no row-level
|
|
59
|
+
-- security, because an organization is not inside one — and then reads the
|
|
60
|
+
-- user through `withOrg` like everything else. There is no cross-tenant read
|
|
61
|
+
-- on the login path, so there is nothing here to open.
|
|
62
|
+
|
|
63
|
+
-- ─────────────────────── the worker role that was only ever a comment ───────────────────────
|
|
64
|
+
--
|
|
65
|
+
-- `0001_init.sql` says: "Background worker jobs run under a separate role with
|
|
66
|
+
-- BYPASSRLS." No migration ever created it. The worker claims documents across
|
|
67
|
+
-- every tenant — that is what a queue is — and those queries run on a raw
|
|
68
|
+
-- connection with no organization set, so they raise the same
|
|
69
|
+
-- `unrecognized configuration parameter` as the credential lookup did:
|
|
70
|
+
--
|
|
71
|
+
-- $ psql "postgres://nacre_app@…" -c "SELECT d.id, d.org_id FROM documents d
|
|
72
|
+
-- JOIN organizations o ON o.id = d.org_id"
|
|
73
|
+
-- ERROR: unrecognized configuration parameter "app.current_org"
|
|
74
|
+
--
|
|
75
|
+
-- So indexing stops entirely on a deployment that connects as anything but a
|
|
76
|
+
-- superuser. The Compose stack has never shown it, because POSTGRES_USER makes
|
|
77
|
+
-- the connecting role a superuser — the configuration docs/config.md tells
|
|
78
|
+
-- operators not to use.
|
|
79
|
+
--
|
|
80
|
+
-- BYPASSRLS rather than another policy flag, and the difference from the
|
|
81
|
+
-- lookup above is worth being explicit about: authentication reads one column
|
|
82
|
+
-- set from one table and never writes, so a narrow SELECT policy fits it. The
|
|
83
|
+
-- worker reads and writes documents, chunks and layers across every tenant, so
|
|
84
|
+
-- a flag admitting all of that is BYPASSRLS with a worse name and a longer
|
|
85
|
+
-- audit trail. The honest thing is to say so in the role.
|
|
86
|
+
--
|
|
87
|
+
-- Which is also why the worker names org_id explicitly in every query it makes
|
|
88
|
+
-- afterwards: with the second line of defence off, the first one is all there
|
|
89
|
+
-- is.
|
|
90
|
+
|
|
91
|
+
DO $$
|
|
92
|
+
BEGIN
|
|
93
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nacre_worker') THEN
|
|
94
|
+
CREATE ROLE nacre_worker NOLOGIN BYPASSRLS;
|
|
95
|
+
ELSIF NOT (SELECT rolbypassrls FROM pg_roles WHERE rolname = 'nacre_worker') THEN
|
|
96
|
+
ALTER ROLE nacre_worker BYPASSRLS;
|
|
97
|
+
END IF;
|
|
98
|
+
EXCEPTION WHEN insufficient_privilege THEN
|
|
99
|
+
-- Creating a BYPASSRLS role needs a superuser, and the role running
|
|
100
|
+
-- migrations may not be one. Failing here with the statement to run is the
|
|
101
|
+
-- whole point: the alternative is a worker that starts, claims nothing,
|
|
102
|
+
-- and reports itself healthy.
|
|
103
|
+
RAISE EXCEPTION USING
|
|
104
|
+
MESSAGE = 'nacre_worker could not be created: creating a BYPASSRLS role requires a superuser.',
|
|
105
|
+
HINT = 'Run this once as a superuser, then re-run migrations: '
|
|
106
|
+
'CREATE ROLE nacre_worker NOLOGIN BYPASSRLS; '
|
|
107
|
+
'GRANT nacre_worker TO <the role in NACRE_PG_URL>;';
|
|
108
|
+
END
|
|
109
|
+
$$;
|
|
110
|
+
|
|
111
|
+
GRANT USAGE ON SCHEMA public TO nacre_worker;
|
|
112
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
|
113
|
+
organizations, users, groups, group_members, service_accounts,
|
|
114
|
+
embedding_providers, workspaces, layers, documents, chunks, grants
|
|
115
|
+
TO nacre_worker;
|
|
116
|
+
GRANT SELECT, INSERT ON audit_events TO nacre_worker;
|
|
117
|
+
REVOKE UPDATE, DELETE ON audit_events FROM nacre_worker;
|
|
118
|
+
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO nacre_worker;
|
|
119
|
+
|
|
120
|
+
-- The application role has to be a member to `SET LOCAL ROLE` to it. Granted
|
|
121
|
+
-- rather than assumed: a deployment that manages roles externally will already
|
|
122
|
+
-- have done this, and one that does not would otherwise get a worker that
|
|
123
|
+
-- connects successfully and cannot claim anything.
|
|
124
|
+
GRANT nacre_worker TO nacre_app;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
-- Refresh tokens, for the login flow.
|
|
2
|
+
--
|
|
3
|
+
-- ─── stored hashed, like every other credential here ───
|
|
4
|
+
--
|
|
5
|
+
-- The column holds a SHA-256 of the token and never the token. A refresh token
|
|
6
|
+
-- outlives an access token by a month, so a database dump that contained them
|
|
7
|
+
-- would be a month of sessions rather than a list of identifiers.
|
|
8
|
+
--
|
|
9
|
+
-- ─── rotation, and what reuse means ───
|
|
10
|
+
--
|
|
11
|
+
-- Every refresh issues a new token and marks the old one used. A used token
|
|
12
|
+
-- presented again is not a client bug worth tolerating: the legitimate holder
|
|
13
|
+
-- has already exchanged it, so a second presentation means two parties hold the
|
|
14
|
+
-- same token, and one of them stole it. There is no way to tell which, so the
|
|
15
|
+
-- whole family is revoked and both are made to sign in again. Annoying once
|
|
16
|
+
-- beats a silent, indefinite session for whoever took it.
|
|
17
|
+
--
|
|
18
|
+
-- `family_id` is what makes that possible: it is the same value for every token
|
|
19
|
+
-- descended from one login, so revoking a family ends that session and no other.
|
|
20
|
+
|
|
21
|
+
CREATE TABLE refresh_tokens (
|
|
22
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
23
|
+
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
24
|
+
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
25
|
+
-- Not unique across the table by accident: it is unique because the token
|
|
26
|
+
-- is 32 bytes from the CSPRNG, and the constraint says so rather than
|
|
27
|
+
-- leaving a collision to become two users sharing a session.
|
|
28
|
+
token_hash text NOT NULL UNIQUE,
|
|
29
|
+
family_id uuid NOT NULL,
|
|
30
|
+
issued_at timestamptz NOT NULL DEFAULT now(),
|
|
31
|
+
expires_at timestamptz NOT NULL,
|
|
32
|
+
used_at timestamptz,
|
|
33
|
+
revoked_at timestamptz
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
-- The lookup on every refresh: by hash, which is also the uniqueness above.
|
|
37
|
+
-- The family index is for revocation, which touches every row of one family.
|
|
38
|
+
CREATE INDEX refresh_tokens_family ON refresh_tokens (family_id) WHERE revoked_at IS NULL;
|
|
39
|
+
-- Expired rows are deleted by a sweep rather than kept forever; ordering by
|
|
40
|
+
-- expiry is what makes that sweep cheap.
|
|
41
|
+
CREATE INDEX refresh_tokens_expiry ON refresh_tokens (expires_at);
|
|
42
|
+
|
|
43
|
+
ALTER TABLE refresh_tokens ENABLE ROW LEVEL SECURITY;
|
|
44
|
+
ALTER TABLE refresh_tokens FORCE ROW LEVEL SECURITY;
|
|
45
|
+
|
|
46
|
+
-- Scoped like everything else. A refresh token names the organization it was
|
|
47
|
+
-- issued for, and the endpoint that redeems one has already resolved that
|
|
48
|
+
-- organization from the token's own row — through the authenticating policy
|
|
49
|
+
-- below, which is the same mechanism service account keys use and for the same
|
|
50
|
+
-- reason: the credential is what says which tenant it belongs to.
|
|
51
|
+
CREATE POLICY org_isolation ON refresh_tokens
|
|
52
|
+
USING (
|
|
53
|
+
CASE
|
|
54
|
+
WHEN current_setting('app.authenticating', true) = 'on' THEN false
|
|
55
|
+
ELSE org_id = current_setting('app.current_org')::uuid
|
|
56
|
+
END
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE POLICY authenticating ON refresh_tokens
|
|
60
|
+
FOR SELECT
|
|
61
|
+
USING (current_setting('app.authenticating', true) = 'on');
|
|
62
|
+
|
|
63
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON refresh_tokens TO nacre_app;
|
|
64
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON refresh_tokens TO nacre_worker;
|
|
65
|
+
|
|
66
|
+
-- ─── the password column gets a check it never had ───
|
|
67
|
+
--
|
|
68
|
+
-- `users.password_hash` has existed since 0001 and nothing has ever written to
|
|
69
|
+
-- it. Now that something does, an empty string must not be storable: it is
|
|
70
|
+
-- falsy in application code and truthy in SQL, which is exactly the shape of
|
|
71
|
+
-- difference that becomes an account anyone can sign into.
|
|
72
|
+
ALTER TABLE users ADD CONSTRAINT users_password_hash_not_blank
|
|
73
|
+
CHECK (password_hash IS NULL OR length(password_hash) >= 16);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Pin the search_path on the one SECURITY DEFINER function.
|
|
2
|
+
--
|
|
3
|
+
-- `0003` made `bump_groups_version()` SECURITY DEFINER and did not pin its
|
|
4
|
+
-- search_path. The body's unqualified `organizations` therefore resolves
|
|
5
|
+
-- through the *caller's* search_path, and any role may create objects in
|
|
6
|
+
-- `pg_temp`. A caller who shadows the table stops `groups_version` advancing —
|
|
7
|
+
-- so revocations never propagate, while `nacre_acl_propagation_lag_seconds`
|
|
8
|
+
-- reports a healthy zero because nothing is behind a version that never moved.
|
|
9
|
+
-- A silent failure of invariant 4, with its own evidence metric confirming
|
|
10
|
+
-- health.
|
|
11
|
+
--
|
|
12
|
+
-- It runs as the migration owner, which `docs/config.md` allows to be a
|
|
13
|
+
-- superuser, so the ceiling is high. Reaching it needs database access as
|
|
14
|
+
-- `nacre_app` — defence in depth rather than a remote hole — which is exactly
|
|
15
|
+
-- what the second line of defence is for in a product whose first line is a
|
|
16
|
+
-- permission model.
|
|
17
|
+
--
|
|
18
|
+
-- A separate migration rather than an edit to 0003: migrations are
|
|
19
|
+
-- forward-only and their checksums are re-verified on every run, so editing an
|
|
20
|
+
-- applied file makes the next `migrate` fail on every existing database.
|
|
21
|
+
|
|
22
|
+
ALTER FUNCTION bump_groups_version() SET search_path = pg_catalog, public;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- A lease on the two background sweeps, so more workers do more work.
|
|
2
|
+
--
|
|
3
|
+
-- `claimStale` (retag) and `claimPurgeable` (garbage collection) both selected
|
|
4
|
+
-- the oldest N rows with no lock and no claim marker. Every replica therefore
|
|
5
|
+
-- selected the **same** N rows and did the same work: throughput stayed at one
|
|
6
|
+
-- worker's rate however many were running, while the load on Qdrant multiplied
|
|
7
|
+
-- by the replica count.
|
|
8
|
+
--
|
|
9
|
+
-- That matters beyond efficiency. The documented response to a climbing
|
|
10
|
+
-- `nacre_acl_propagation_lag_seconds` is to look at the worker — and scaling it
|
|
11
|
+
-- out, the obvious move, did nothing at all.
|
|
12
|
+
--
|
|
13
|
+
-- `FOR UPDATE SKIP LOCKED` alone does not fix it. The lock lives until the
|
|
14
|
+
-- transaction commits, and these transactions end the moment the SELECT
|
|
15
|
+
-- returns — the actual work (a Qdrant round trip) happens afterwards, outside
|
|
16
|
+
-- any transaction. So two replicas polling a second apart still collide.
|
|
17
|
+
--
|
|
18
|
+
-- ─── one column, because the two states are mutually exclusive ───
|
|
19
|
+
--
|
|
20
|
+
-- A document is either live and behind its organization's version (retag), or
|
|
21
|
+
-- deleted and not yet purged (garbage collection). `deleted_at IS NULL` decides
|
|
22
|
+
-- which, and nothing is ever both, so one claim column serves both sweeps.
|
|
23
|
+
--
|
|
24
|
+
-- Leased rather than owned, exactly like `documents.claimed_at`: a worker that
|
|
25
|
+
-- dies mid-sweep must not park a row forever. `NACRE_INDEX_LEASE` bounds it,
|
|
26
|
+
-- and the claim is only ever an optimisation — both sweeps are idempotent, and
|
|
27
|
+
-- `markTagged`'s version guard and `markPurged`'s `vectors_purged_at IS NULL`
|
|
28
|
+
-- guard remain the things that make a double pass harmless.
|
|
29
|
+
|
|
30
|
+
ALTER TABLE documents ADD COLUMN sweep_claimed_at timestamptz;
|
|
31
|
+
|
|
32
|
+
-- Partial, and on the claim rather than on the whole table: the sweeps read
|
|
33
|
+
-- "unclaimed, or claimed long enough ago to be abandoned", which is a small
|
|
34
|
+
-- slice of a large table.
|
|
35
|
+
CREATE INDEX documents_sweep_claim ON documents (sweep_claimed_at)
|
|
36
|
+
WHERE sweep_claimed_at IS NOT NULL;
|