@nacre.work/core 0.0.0 → 0.1.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/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 +2 -2
- package/dist/authz/index.d.ts.map +1 -1
- package/dist/authz/index.js +1 -1
- package/dist/authz/index.js.map +1 -1
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +137 -3
- 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 +15 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -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 +97 -0
- package/dist/lifecycle.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 +18 -1
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +77 -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/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 +81 -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/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 +109 -4
- package/dist/vector/search.d.ts.map +1 -1
- package/dist/vector/search.js +166 -7
- package/dist/vector/search.js.map +1 -1
- package/package.json +26 -8
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every process does when it is asked to stop, and when it is about to
|
|
3
|
+
* die for a reason nobody wrote down.
|
|
4
|
+
*
|
|
5
|
+
* Three processes ship here — api, mcp, worker — and none of them had any of
|
|
6
|
+
* this. The consequences were all of the same shape: a failure that produced no
|
|
7
|
+
* log line, so the operator saw a container restart and had nothing to read.
|
|
8
|
+
*
|
|
9
|
+
* - **No `unhandledRejection` handler.** Node terminates the process on one,
|
|
10
|
+
* with a raw stack on stderr and no service name, no timestamp in the format
|
|
11
|
+
* every other line uses, and nothing to join to a request.
|
|
12
|
+
* - **No `error` listener on `listen`.** `EADDRINUSE` is emitted on the server
|
|
13
|
+
* *after* `main()` has already resolved, so `main().catch` never sees it and
|
|
14
|
+
* the careful ConfigError path — which exits 2 with an explanation — is
|
|
15
|
+
* bypassed by the one startup failure an operator hits most.
|
|
16
|
+
* - **No deadline on shutdown.** `server.close()` waits for in-flight requests
|
|
17
|
+
* with no upper bound. One request blocked on a slow dependency means the
|
|
18
|
+
* callback never runs, the pool is never released, and the orchestrator
|
|
19
|
+
* SIGKILLs at the end of its grace period — turning every rolling deploy into
|
|
20
|
+
* an abrupt one, silently.
|
|
21
|
+
*/
|
|
22
|
+
const log = (service, msg, extra = {}) => {
|
|
23
|
+
console.error(JSON.stringify({ msg, service, ...extra }));
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Install the handlers. Returns a `stop` to call from a signal handler.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent: a second SIGTERM while shutting down is ignored rather than
|
|
29
|
+
* starting a second shutdown, because an orchestrator that sends one and then
|
|
30
|
+
* loses patience sends another.
|
|
31
|
+
*/
|
|
32
|
+
export function installGuards(guards) {
|
|
33
|
+
const { service, shutdown, graceMs = 10_000, exit = (code) => process.exit(code) } = guards;
|
|
34
|
+
let stopping = false;
|
|
35
|
+
process.on('unhandledRejection', (reason) => {
|
|
36
|
+
// Logged and then rethrown by exiting, rather than swallowed. A promise
|
|
37
|
+
// nobody awaited failing means the process is in a state its author did not
|
|
38
|
+
// plan for, and continuing from there is how one bad request becomes a
|
|
39
|
+
// corrupt one.
|
|
40
|
+
log(service, 'unhandled rejection; exiting', { error: String(reason).slice(0, 500) });
|
|
41
|
+
exit(1);
|
|
42
|
+
});
|
|
43
|
+
process.on('uncaughtException', (error) => {
|
|
44
|
+
log(service, 'uncaught exception; exiting', {
|
|
45
|
+
error: String(error).slice(0, 500),
|
|
46
|
+
stack: error.stack?.split('\n').slice(1, 6).join(' | '),
|
|
47
|
+
});
|
|
48
|
+
exit(1);
|
|
49
|
+
});
|
|
50
|
+
const stop = (signal) => {
|
|
51
|
+
if (stopping)
|
|
52
|
+
return;
|
|
53
|
+
stopping = true;
|
|
54
|
+
log(service, 'shutting down', { signal });
|
|
55
|
+
// The deadline runs whatever happens, and `unref` keeps it from being the
|
|
56
|
+
// thing that holds the process open once everything else has let go.
|
|
57
|
+
const deadline = setTimeout(() => {
|
|
58
|
+
log(service, 'shutdown did not finish in time; exiting anyway', { grace_ms: graceMs });
|
|
59
|
+
exit(1);
|
|
60
|
+
}, graceMs);
|
|
61
|
+
deadline.unref();
|
|
62
|
+
void (async () => {
|
|
63
|
+
try {
|
|
64
|
+
await shutdown?.();
|
|
65
|
+
clearTimeout(deadline);
|
|
66
|
+
exit(0);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
log(service, 'shutdown failed', { error: String(error).slice(0, 500) });
|
|
70
|
+
clearTimeout(deadline);
|
|
71
|
+
exit(1);
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
};
|
|
75
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
76
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
77
|
+
return { stop };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Fail a `listen` the way every other startup failure fails.
|
|
81
|
+
*
|
|
82
|
+
* `server.listen(port, cb)` reports a bound port through the callback and a
|
|
83
|
+
* refusal through an `error` event — which arrives after `main()` has resolved,
|
|
84
|
+
* so nothing was catching it.
|
|
85
|
+
*/
|
|
86
|
+
export function onListenError(server, service, port, exit = (code) => process.exit(code)) {
|
|
87
|
+
server.on('error', (error) => {
|
|
88
|
+
const detail = error.code === 'EADDRINUSE'
|
|
89
|
+
? `port ${port} is already in use`
|
|
90
|
+
: error.code === 'EACCES'
|
|
91
|
+
? `port ${port} needs privileges this process does not have`
|
|
92
|
+
: String(error);
|
|
93
|
+
log(service, 'could not listen', { port, detail });
|
|
94
|
+
exit(2);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=lifecycle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../lifecycle.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { MetadataValue } from './authz/filter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Document metadata: what a caller may write, and what they may narrow a search
|
|
4
|
+
* by.
|
|
5
|
+
*
|
|
6
|
+
* One module for both ends on purpose. Ingest and search accept the same shape
|
|
7
|
+
* from the same untrusted place, and two validators would drift — the ingest
|
|
8
|
+
* side would grow a type the search side could not express, or the search side
|
|
9
|
+
* would accept a key ingest had refused, and a caller would be filtering on a
|
|
10
|
+
* field that can never exist.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is a bound. Metadata is written into the vector payload of
|
|
13
|
+
* *every chunk of a document*, so a key is stored once per point and a
|
|
14
|
+
* thousand-chunk document multiplies whatever arrives here by a thousand. The
|
|
15
|
+
* limits are deliberately small; a knowledge index wants `source`, `team`,
|
|
16
|
+
* `updated_at`, not a document's worth of JSON a second time.
|
|
17
|
+
*/
|
|
18
|
+
/** Keys per document. */
|
|
19
|
+
export declare const MAX_METADATA_KEYS = 32;
|
|
20
|
+
export declare const MAX_METADATA_KEY_LENGTH = 64;
|
|
21
|
+
/** A string value, and every string inside a list value. */
|
|
22
|
+
export declare const MAX_METADATA_VALUE_LENGTH = 256;
|
|
23
|
+
/** Values in one list. */
|
|
24
|
+
export declare const MAX_METADATA_LIST = 32;
|
|
25
|
+
export type Metadata = Readonly<Record<string, MetadataValue>>;
|
|
26
|
+
export declare class MetadataError extends Error {
|
|
27
|
+
constructor(message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate and normalise metadata arriving from a request.
|
|
31
|
+
*
|
|
32
|
+
* Raises rather than dropping what it does not like. A silently discarded key
|
|
33
|
+
* is a document a caller believes is tagged and a filter that will never match
|
|
34
|
+
* it — the same class of failure as a search parameter read by nothing, and the
|
|
35
|
+
* reason `filters` answered 400 rather than being ignored.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseMetadata(raw: unknown, what?: string): Metadata;
|
|
38
|
+
/**
|
|
39
|
+
* The same bounds, plus the one rule that belongs only to filtering.
|
|
40
|
+
*
|
|
41
|
+
* An empty list is a legitimate *value* — a document may be tagged with no
|
|
42
|
+
* teams — and an impossible *restriction*: "team is one of nothing" matches
|
|
43
|
+
* nothing at all. `buildFilter` refuses to encode it, deliberately, so that
|
|
44
|
+
* what an impossible restriction means stays the caller's decision rather than
|
|
45
|
+
* the query builder's. This is where that decision is made for a request: a 400
|
|
46
|
+
* naming the key, because a client that built a list and put nothing in it has
|
|
47
|
+
* a bug, and answering with an empty result would hide it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseFilters(raw: unknown): Metadata;
|
|
50
|
+
//# sourceMappingURL=metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEtD;;;;;;;;;;;;;;;GAeG;AAEH,yBAAyB;AACzB,eAAO,MAAM,iBAAiB,KAAK,CAAA;AACnC,eAAO,MAAM,uBAAuB,KAAK,CAAA;AACzC,4DAA4D;AAC5D,eAAO,MAAM,yBAAyB,MAAM,CAAA;AAC5C,0BAA0B;AAC1B,eAAO,MAAM,iBAAiB,KAAK,CAAA;AAanC,MAAM,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAA;AAE9D,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,EAAE,MAAM;CAI5B;AA6BD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,SAAa,GAAG,QAAQ,CAsCvE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,QAAQ,CAWnD"}
|
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,9 +82,11 @@ 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;
|
|
74
91
|
};
|
|
75
92
|
export type Metrics = ReturnType<typeof createMetrics>;
|
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;;;;;;;;;;EAgE/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,9 +221,33 @@ 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')),
|
|
177
252
|
};
|
|
178
253
|
}
|
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":"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"}
|
|
@@ -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;
|