@nacre.work/api 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/adapters.d.ts +421 -17
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +1092 -72
- package/dist/adapters.js.map +1 -1
- package/dist/audit-export.d.ts +45 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +155 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -7
- package/dist/auth.js.map +1 -1
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +147 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +50 -5
- package/dist/init.js.map +1 -1
- package/dist/limits.d.ts +80 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +63 -0
- package/dist/limits.js.map +1 -0
- package/dist/login.d.ts +94 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +283 -0
- package/dist/login.js.map +1 -0
- package/dist/main.js +171 -60
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +72 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +110 -0
- package/dist/pagination.js.map +1 -0
- package/dist/rerank.d.ts +99 -0
- package/dist/rerank.d.ts.map +1 -0
- package/dist/rerank.js +138 -0
- package/dist/rerank.js.map +1 -0
- package/dist/server.d.ts +361 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1050 -31
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts +13 -5
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +54 -31
- package/dist/service-keys.js.map +1 -1
- package/dist/source.d.ts +53 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/package.json +28 -10
- package/dist/__tests__/admin-surface.test.d.ts +0 -2
- package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
- package/dist/__tests__/admin-surface.test.js +0 -431
- package/dist/__tests__/admin-surface.test.js.map +0 -1
- package/dist/__tests__/init.test.d.ts +0 -2
- package/dist/__tests__/init.test.d.ts.map +0 -1
- package/dist/__tests__/init.test.js +0 -125
- package/dist/__tests__/init.test.js.map +0 -1
- package/dist/__tests__/service-keys.test.d.ts +0 -2
- package/dist/__tests__/service-keys.test.d.ts.map +0 -1
- package/dist/__tests__/service-keys.test.js +0 -204
- package/dist/__tests__/service-keys.test.js.map +0 -1
package/dist/adapters.js
CHANGED
|
@@ -1,11 +1,37 @@
|
|
|
1
|
-
import { aclTags, buildFilter, effectivePrincipals, loadGrants, loadScopeTree, PostgresGroupGraph, referenceAllows, resolve, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
|
|
1
|
+
import { aclTags, buildFilter, cachedEffectivePrincipals, documentKey, effectivePrincipals, fromStateJson, loadGrants, loadGroupsVersion, loadScopeTree, PostgresGroupGraph, referenceAllows, reindexProgress, resolve, toStateJson, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { encodeCursor, pageOf } from './pagination.js';
|
|
4
|
+
import { applyRanking } from './rerank.js';
|
|
3
5
|
export class PostgresDocuments {
|
|
4
6
|
pool;
|
|
7
|
+
payload;
|
|
5
8
|
role;
|
|
6
|
-
|
|
9
|
+
principalsCache;
|
|
10
|
+
presign;
|
|
11
|
+
constructor(pool,
|
|
12
|
+
/**
|
|
13
|
+
* What writes metadata into the payload of a document's points.
|
|
14
|
+
*
|
|
15
|
+
* Narrow on purpose, like the delete path's tombstone port: this endpoint
|
|
16
|
+
* has no business searching, and a port that could would let a later change
|
|
17
|
+
* reach the index without a plan.
|
|
18
|
+
*/
|
|
19
|
+
payload, role,
|
|
20
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
21
|
+
principalsCache,
|
|
22
|
+
/**
|
|
23
|
+
* Where a `source_url` comes from, when a deployment has object storage.
|
|
24
|
+
*
|
|
25
|
+
* Absent leaves the field off the response entirely, which is what every
|
|
26
|
+
* deployment without a bucket should see: a document whose bytes are in
|
|
27
|
+
* `documents.source_ref` has nothing to link to.
|
|
28
|
+
*/
|
|
29
|
+
presign) {
|
|
7
30
|
this.pool = pool;
|
|
31
|
+
this.payload = payload;
|
|
8
32
|
this.role = role;
|
|
33
|
+
this.principalsCache = principalsCache;
|
|
34
|
+
this.presign = presign;
|
|
9
35
|
}
|
|
10
36
|
/**
|
|
11
37
|
* Undefined for absent, for another organization's, and for one this caller
|
|
@@ -27,10 +53,11 @@ export class PostgresDocuments {
|
|
|
27
53
|
// The plan first. Reading the row and then checking is the same
|
|
28
54
|
// information leak with extra steps if the check is ever forgotten,
|
|
29
55
|
// and the layer a document sits in is what the grant is on.
|
|
30
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
56
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
31
57
|
if (plan.kind === 'none')
|
|
32
58
|
return undefined;
|
|
33
|
-
const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
|
|
59
|
+
const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
|
|
60
|
+
d.updated_at, d.metadata, d.source_type, d.source_ref
|
|
34
61
|
FROM documents d
|
|
35
62
|
JOIN layers l ON l.id = d.layer_id AND l.org_id = d.org_id
|
|
36
63
|
WHERE d.org_id = $1 AND d.id = $2 AND d.deleted_at IS NULL`, [orgId, documentId]);
|
|
@@ -54,53 +81,342 @@ export class PostgresDocuments {
|
|
|
54
81
|
status: row.status,
|
|
55
82
|
chunk_count: Number(row.chunk_count),
|
|
56
83
|
updated_at: row.updated_at.toISOString(),
|
|
84
|
+
// Read back, because a caller who tags a document and cannot see the
|
|
85
|
+
// tag has no way to tell a successful write from a dropped one — and
|
|
86
|
+
// this field was dropped by the handler for as long as it existed.
|
|
87
|
+
metadata: (row.metadata ?? {}),
|
|
88
|
+
// Minted here and nowhere earlier: everything above this line is the
|
|
89
|
+
// permission check, so a link exists only for a caller who has just
|
|
90
|
+
// been found to hold `read` on this document. `write` alone does not
|
|
91
|
+
// reach here — rule 6 — and neither does a denied document.
|
|
92
|
+
//
|
|
93
|
+
// Only for `s3`. An `inline` document has no object, and a `url` one
|
|
94
|
+
// is somebody else's address that this system has no business
|
|
95
|
+
// signing.
|
|
96
|
+
...(this.presign !== undefined && row.source_type === 's3' && row.source_ref !== null
|
|
97
|
+
? { source_url: this.presign.url(row.source_ref) }
|
|
98
|
+
: {}),
|
|
57
99
|
};
|
|
58
100
|
}, this.role === undefined ? {} : { role: this.role });
|
|
59
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Replace a document's metadata without re-embedding it.
|
|
104
|
+
*
|
|
105
|
+
* This is the cheap path `docs/api.md` said was not built. Ingest treats a
|
|
106
|
+
* metadata change as a change: the row and the vector payload would otherwise
|
|
107
|
+
* disagree and the document would carry a tag it does not answer to, so a
|
|
108
|
+
* re-tag cost a full re-parse, re-chunk and re-embed. Here the vectors are
|
|
109
|
+
* untouched and only the payload is rewritten — the same `setPayload` the ACL
|
|
110
|
+
* retag sweep uses, for the same reason.
|
|
111
|
+
*
|
|
112
|
+
* `write`, not `read`, and rule 6 means those are not the same set. The
|
|
113
|
+
* answer carries no body at all: a caller who may write to a document and not
|
|
114
|
+
* read it must not learn its title or its layer from a successful PATCH.
|
|
115
|
+
*
|
|
116
|
+
* Row first, then payload, both inside one transaction. A payload write that
|
|
117
|
+
* throws rolls the row back, so the two cannot end up disagreeing in the
|
|
118
|
+
* direction that matters — the row is what the worker rebuilds the payload
|
|
119
|
+
* from on the next index, so a row that ran ahead would be reverted while a
|
|
120
|
+
* payload that ran ahead would not.
|
|
121
|
+
*/
|
|
122
|
+
async updateMetadata(auth, documentId, metadata) {
|
|
123
|
+
if (!/^[0-9a-f-]{36}$/i.test(documentId))
|
|
124
|
+
return false;
|
|
125
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
126
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
127
|
+
const collection = orgs[0]?.vector_collection;
|
|
128
|
+
if (collection === undefined)
|
|
129
|
+
return false;
|
|
130
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'write');
|
|
131
|
+
if (plan.kind === 'none')
|
|
132
|
+
return false;
|
|
133
|
+
const { rows } = await client.query(`SELECT layer_id FROM documents
|
|
134
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, documentId]);
|
|
135
|
+
const layerId = rows[0]?.layer_id;
|
|
136
|
+
if (layerId === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
if (plan.kind === 'scoped') {
|
|
139
|
+
// deniedDocs first, as on the delete path: a deny beats an allow at
|
|
140
|
+
// any depth, and checking only `layers` would let a document inside
|
|
141
|
+
// an allowed layer be retagged despite an explicit deny on it.
|
|
142
|
+
if (plan.deniedDocs.includes(documentId))
|
|
143
|
+
return false;
|
|
144
|
+
if (!plan.layers.includes(layerId) && !plan.extraDocs.includes(documentId))
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
await client.query(`UPDATE documents SET metadata = $3, updated_at = now()
|
|
148
|
+
WHERE org_id = $1 AND id = $2`, [auth.orgId, documentId, JSON.stringify(metadata)]);
|
|
149
|
+
// `version` is deliberately not bumped. It counts revisions of the
|
|
150
|
+
// document's content, and a tag change is not one — bumping it would
|
|
151
|
+
// make every retag look like an edit to anything watching for one.
|
|
152
|
+
await this.payload.setMetadata(collection, documentId, metadata);
|
|
153
|
+
return true;
|
|
154
|
+
}, this.role === undefined ? {} : { role: this.role });
|
|
155
|
+
}
|
|
60
156
|
}
|
|
61
157
|
export class NacreSearchService {
|
|
62
158
|
deps;
|
|
63
159
|
constructor(deps) {
|
|
64
160
|
this.deps = deps;
|
|
65
161
|
}
|
|
66
|
-
async search(auth, query, topK) {
|
|
67
|
-
const
|
|
68
|
-
|
|
162
|
+
async search(auth, query, topK, options = {}) {
|
|
163
|
+
const context = await withOrg(this.deps.pool, auth.orgId, async (client) => {
|
|
164
|
+
// The collection, from the column that owns it. Read in the transaction
|
|
165
|
+
// the plan needs anyway rather than through a cache: a reindex moves
|
|
166
|
+
// this pointer, and a cached one means the API keeps searching the
|
|
167
|
+
// collection the worker has stopped writing to. That failure returns
|
|
168
|
+
// results, just increasingly stale ones, which is the kind nobody
|
|
169
|
+
// notices.
|
|
170
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
171
|
+
const collection = orgs[0]?.vector_collection;
|
|
172
|
+
if (collection === undefined)
|
|
173
|
+
return undefined;
|
|
174
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
175
|
+
const grants = await loadGrants(client, auth.orgId, principals);
|
|
176
|
+
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
177
|
+
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
|
|
178
|
+
// Every live layer with the model it is on. Small — an installation
|
|
179
|
+
// runs tens of layers per organization, not thousands — and it is what
|
|
180
|
+
// decides how many branches the query has.
|
|
181
|
+
const { rows: layers } = await client.query(`SELECT l.id, l.vector_name, p.id AS provider_id, p.endpoint, p.model, p.dimensions
|
|
182
|
+
FROM layers l
|
|
183
|
+
JOIN embedding_providers p ON p.id = l.provider_id
|
|
184
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL`, [auth.orgId]);
|
|
185
|
+
return { collection, plan, layers };
|
|
186
|
+
}, this.deps.role === undefined ? {} : { role: this.deps.role });
|
|
187
|
+
if (context === undefined) {
|
|
69
188
|
// The token names an organization that is not there. Rule I3: a
|
|
70
189
|
// permission that cannot be evaluated denies.
|
|
71
190
|
return [];
|
|
72
191
|
}
|
|
73
|
-
const
|
|
74
|
-
const graph = await PostgresGroupGraph.load(client, auth.orgId);
|
|
75
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
76
|
-
const grants = await loadGrants(client, auth.orgId, principals);
|
|
77
|
-
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
78
|
-
return resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
|
|
79
|
-
}, this.deps.role === undefined ? {} : { role: this.deps.role });
|
|
192
|
+
const { collection, plan } = context;
|
|
80
193
|
// No access at all. Returning early rather than querying is not an
|
|
81
194
|
// optimization — buildFilter refuses this plan, and it refuses it because
|
|
82
195
|
// there is no filter that means "nothing" without meaning "everything" by
|
|
83
196
|
// one mistake.
|
|
84
197
|
if (plan.kind === 'none')
|
|
85
198
|
return [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
199
|
+
// The caller's own restriction, resolved before the query.
|
|
200
|
+
//
|
|
201
|
+
// `layers` was declared in openapi.yaml and in the MCP tool schema from the
|
|
202
|
+
// beginning and read by nothing, so a client scoping a search to one layer
|
|
203
|
+
// silently searched all of them. For a product whose selling point is that
|
|
204
|
+
// a search returns only what you may see, a scoping parameter that does
|
|
205
|
+
// nothing is the worst kind of no-op: the caller believes they narrowed it.
|
|
206
|
+
let narrow;
|
|
207
|
+
if (options.filters !== undefined && Object.keys(options.filters).length > 0) {
|
|
208
|
+
// Metadata alone is a valid narrowing — a caller may filter by source
|
|
209
|
+
// without naming a layer. `buildFilter` turns each entry into a `must`
|
|
210
|
+
// beside the permission constraint; there is no path from here to a
|
|
211
|
+
// filter the caller assembled.
|
|
212
|
+
narrow = { metadata: options.filters };
|
|
213
|
+
}
|
|
214
|
+
if (options.layers !== undefined && options.layers.length > 0) {
|
|
215
|
+
const resolved = await this.layerIds(auth.orgId, options.layers);
|
|
216
|
+
// Intersected with what the plan actually reaches, before deciding
|
|
217
|
+
// whether to query at all.
|
|
218
|
+
//
|
|
219
|
+
// The filter would already return nothing for a layer this caller cannot
|
|
220
|
+
// read — `must` on layer_id against a `should` that names other layers
|
|
221
|
+
// matches no point. Doing it here as well closes a narrower hole: without
|
|
222
|
+
// it, an unreadable-but-existing layer runs the query and an absent one
|
|
223
|
+
// does not, so anything that makes the query path *fail* — the vector
|
|
224
|
+
// store being down — separates the two. That is invariant I4 leaking
|
|
225
|
+
// through a dependency outage, and it costs six lines to remove. It also
|
|
226
|
+
// saves a round trip in the ordinary case.
|
|
227
|
+
//
|
|
228
|
+
// Not intersected when the plan carries `extraDocs`: those are documents
|
|
229
|
+
// granted individually and they can sit in any layer, including one the
|
|
230
|
+
// caller reaches no other way. Dropping such a layer here would answer
|
|
231
|
+
// empty where the correct answer is "the documents you were granted".
|
|
232
|
+
const permitted = plan.kind === 'scoped' && plan.extraDocs.length === 0
|
|
233
|
+
? resolved.filter((id) => plan.layers.includes(id))
|
|
234
|
+
: resolved;
|
|
235
|
+
// Nothing the caller named is reachable. That covers a slug that does not
|
|
236
|
+
// exist and one that does but sits outside their grants — and it has to,
|
|
237
|
+
// because invariant I4 makes those the same answer. Empty results, no
|
|
238
|
+
// error naming the layer, and no query.
|
|
239
|
+
if (permitted.length === 0)
|
|
240
|
+
return [];
|
|
241
|
+
narrow = { ...narrow, layers: permitted };
|
|
242
|
+
}
|
|
243
|
+
// One branch per model actually in scope.
|
|
244
|
+
//
|
|
245
|
+
// Almost always exactly one: an organization runs a single embedding model
|
|
246
|
+
// and every layer is on it. Two while a reindex is part-way through, and
|
|
247
|
+
// two for as long as an operator keeps layers on different providers.
|
|
248
|
+
// Zero means the organization has no layers, or none the caller can reach,
|
|
249
|
+
// and there is nothing to ask the index.
|
|
250
|
+
const groups = this.groupsInScope(context.layers, plan, narrow);
|
|
251
|
+
if (groups.length === 0)
|
|
252
|
+
return [];
|
|
253
|
+
const branches = await Promise.all(groups.map(async (group) => {
|
|
254
|
+
const [vector] = await this.deps.embedderFor(group.provider).embed([query]);
|
|
255
|
+
if (vector === undefined) {
|
|
256
|
+
throw new Error(`the embedder for ${group.provider.model} returned no vector for the query`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
kind: 'dense',
|
|
260
|
+
using: group.vectorName,
|
|
261
|
+
vector,
|
|
262
|
+
// Confined to the layers on this model. A reindexed layer keeps its
|
|
263
|
+
// old vector until the collection is rebuilt without it, so without
|
|
264
|
+
// this it would match the other model's branch too and fusion would
|
|
265
|
+
// rank it above everything for no reason but having been migrated.
|
|
266
|
+
//
|
|
267
|
+
// Only when there is more than one group: with a single model this
|
|
268
|
+
// clause is every layer in the organization, which is a longer filter
|
|
269
|
+
// that removes nothing.
|
|
270
|
+
...(groups.length > 1 ? { onlyLayers: group.layerIds } : {}),
|
|
271
|
+
};
|
|
272
|
+
}));
|
|
273
|
+
// Off unless the deployment configured a reranker and the caller left it on.
|
|
274
|
+
const reranking = this.deps.reranker !== undefined && options.rerank !== false;
|
|
89
275
|
const hits = await this.deps.vectors.search({
|
|
90
276
|
orgId: auth.orgId,
|
|
91
|
-
|
|
277
|
+
collection,
|
|
92
278
|
plan,
|
|
93
|
-
branches
|
|
94
|
-
//
|
|
95
|
-
// costs more.
|
|
96
|
-
|
|
279
|
+
branches,
|
|
280
|
+
// Without a reranker this is passed through uncorrected: asking for more
|
|
281
|
+
// and trimming would be a post-filter that also costs more.
|
|
282
|
+
//
|
|
283
|
+
// With one it is widened, and that is not the same thing. The trim a
|
|
284
|
+
// reranker performs is on relevance over a set the index has already
|
|
285
|
+
// filtered by permission, so it changes *which* permitted results come
|
|
286
|
+
// back and never how many. See rerank.ts — the distinction is the whole
|
|
287
|
+
// argument for this being allowed at all.
|
|
288
|
+
topK: reranking ? Math.max(topK, this.deps.rerankCandidates ?? 50) : topK,
|
|
289
|
+
// A `must` on layer_id inside the traversal, never a trim afterwards.
|
|
290
|
+
// Narrowing a search is still a pre-filter: `top_k` comes back full from
|
|
291
|
+
// the smaller set rather than being cut down from the larger one.
|
|
292
|
+
...(narrow === undefined ? {} : { narrow }),
|
|
97
293
|
});
|
|
98
294
|
// Invariant I1, checked again on the way out. Raises rather than filtering:
|
|
99
295
|
// silently dropping the row would hide the bug that produced it.
|
|
100
296
|
const checked = VectorStore.assertTenant(auth.orgId, hits);
|
|
101
297
|
if (checked.length === 0)
|
|
102
298
|
return [];
|
|
103
|
-
|
|
299
|
+
const candidates = await this.hydrate(auth.orgId, checked);
|
|
300
|
+
const ranked = reranking ? await this.rerank(query, candidates, topK) : candidates;
|
|
301
|
+
// Last, because reranking scores the query against the text. Dropping it
|
|
302
|
+
// earlier would rerank against nothing.
|
|
303
|
+
return options.includeContent === false ? ranked.map((hit) => ({ ...hit, text: '' })) : ranked;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The organization's layers, grouped by the model they are indexed with.
|
|
307
|
+
*
|
|
308
|
+
* Restricted first to what this search can actually reach — the permission
|
|
309
|
+
* plan, then the caller's own `layers` narrowing — so a query never embeds
|
|
310
|
+
* against a provider whose layers contribute nothing. That is not only a
|
|
311
|
+
* saving: an embedding endpoint that is down would otherwise fail a search
|
|
312
|
+
* over layers that have no connection to it.
|
|
313
|
+
*
|
|
314
|
+
* A plan carrying `extraDocs` keeps every group. Documents granted
|
|
315
|
+
* individually can sit in any layer, including one the caller reaches no
|
|
316
|
+
* other way, so dropping a group here would answer empty where the correct
|
|
317
|
+
* answer is "the documents you were granted".
|
|
318
|
+
*/
|
|
319
|
+
groupsInScope(layers, plan, narrow) {
|
|
320
|
+
const reachable = layers.filter((l) => {
|
|
321
|
+
// Only the layer half narrows the branch set. A metadata restriction says
|
|
322
|
+
// nothing about which models are in scope: it removes points inside the
|
|
323
|
+
// query rather than removing layers from it.
|
|
324
|
+
if (narrow?.layers !== undefined && !narrow.layers.includes(l.id))
|
|
325
|
+
return false;
|
|
326
|
+
if (plan.kind === 'all')
|
|
327
|
+
return true;
|
|
328
|
+
if (plan.extraDocs.length > 0)
|
|
329
|
+
return true;
|
|
330
|
+
return plan.layers.includes(l.id);
|
|
331
|
+
});
|
|
332
|
+
const byVector = new Map();
|
|
333
|
+
for (const layer of reachable) {
|
|
334
|
+
// `vector_name` and `provider_id` have to agree, because the first says
|
|
335
|
+
// which slot to search and the second says which model to embed the query
|
|
336
|
+
// with. They disagreed once already — a reindex switched the vector and
|
|
337
|
+
// left the provider behind — and the symptom was Qdrant refusing the
|
|
338
|
+
// whole query on a dimension mismatch, which takes every *other* layer in
|
|
339
|
+
// the organization down with it. Named here rather than left to arrive as
|
|
340
|
+
// `Bad Request`, and raised rather than skipped: dropping the layer from
|
|
341
|
+
// the branch set would answer a search with a silently smaller corpus.
|
|
342
|
+
const expected = vectorName(layer.model, layer.dimensions);
|
|
343
|
+
if (expected !== layer.vector_name) {
|
|
344
|
+
throw new Error(`layer ${layer.id} names vector ${layer.vector_name} but its provider is ` +
|
|
345
|
+
`${layer.model}/${layer.dimensions}, which is ${expected}. A reindex left the two ` +
|
|
346
|
+
'out of step; the layer cannot be searched until they agree.');
|
|
347
|
+
}
|
|
348
|
+
const group = byVector.get(layer.vector_name) ?? {
|
|
349
|
+
provider: {
|
|
350
|
+
id: layer.provider_id,
|
|
351
|
+
endpoint: layer.endpoint,
|
|
352
|
+
model: layer.model,
|
|
353
|
+
dimensions: layer.dimensions,
|
|
354
|
+
},
|
|
355
|
+
layerIds: [],
|
|
356
|
+
};
|
|
357
|
+
group.layerIds.push(layer.id);
|
|
358
|
+
byVector.set(layer.vector_name, group);
|
|
359
|
+
}
|
|
360
|
+
return [...byVector].map(([vectorName, group]) => ({
|
|
361
|
+
vectorName,
|
|
362
|
+
provider: group.provider,
|
|
363
|
+
layerIds: group.layerIds,
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Layer slugs to ids, within one organization.
|
|
368
|
+
*
|
|
369
|
+
* Scoped through `withOrg` like every other tenant read, so a slug belonging
|
|
370
|
+
* to another organization resolves to nothing rather than to their layer. The
|
|
371
|
+
* result is intersected with what the caller may reach by the filter itself —
|
|
372
|
+
* the ids become a `must` and the permission constraint stays a `should`, so
|
|
373
|
+
* a layer this caller cannot read contributes no results even when it
|
|
374
|
+
* resolves here.
|
|
375
|
+
*
|
|
376
|
+
* Unknown slugs are dropped rather than reported. Answering "no such layer"
|
|
377
|
+
* would separate "does not exist" from "you cannot see it", which is exactly
|
|
378
|
+
* the distinction invariant I4 exists to prevent.
|
|
379
|
+
*/
|
|
380
|
+
async layerIds(orgId, slugs) {
|
|
381
|
+
// Bounded, because this is an unauthenticated-shaped input on an
|
|
382
|
+
// authenticated path: an array of ten thousand slugs is one query with ten
|
|
383
|
+
// thousand parameters otherwise.
|
|
384
|
+
const wanted = [...new Set(slugs.map((s) => s.trim().toLowerCase()).filter((s) => s !== ''))].slice(0, 64);
|
|
385
|
+
if (wanted.length === 0)
|
|
386
|
+
return [];
|
|
387
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
388
|
+
const { rows } = await client.query('SELECT id FROM layers WHERE org_id = $1 AND slug = ANY($2::text[])', [orgId, wanted]);
|
|
389
|
+
return rows.map((r) => r.id);
|
|
390
|
+
}, this.deps.role === undefined ? {} : { role: this.deps.role });
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Reorder by cross-encoder score, or answer in fusion order and say so.
|
|
394
|
+
*
|
|
395
|
+
* The text comes from the hydration that had to happen anyway. A reranker
|
|
396
|
+
* scores query against text, and the vector store holds no text — so the
|
|
397
|
+
* order here is search, hydrate the candidates, rerank, cut. Hydrating fifty
|
|
398
|
+
* rows rather than ten is one query with a wider `ANY($2)`, which is the cost
|
|
399
|
+
* of the quality this buys.
|
|
400
|
+
*
|
|
401
|
+
* Failure answers with what fusion produced rather than raising. Reranking
|
|
402
|
+
* decides ordering over candidates that were all permitted before it saw
|
|
403
|
+
* them, so a reranker being down is a quality degradation and not a
|
|
404
|
+
* permissions one — and making the product's central operation depend on an
|
|
405
|
+
* optional model server would be the worse trade. It is reported, never
|
|
406
|
+
* silent.
|
|
407
|
+
*/
|
|
408
|
+
async rerank(query, candidates, topK) {
|
|
409
|
+
const reranker = this.deps.reranker;
|
|
410
|
+
if (reranker === undefined)
|
|
411
|
+
return candidates.slice(0, topK);
|
|
412
|
+
try {
|
|
413
|
+
const scores = await reranker.rank(query, candidates.map((c) => c.text));
|
|
414
|
+
return applyRanking(candidates, scores, topK);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
this.deps.onRerankFailed?.(error);
|
|
418
|
+
return candidates.slice(0, topK);
|
|
419
|
+
}
|
|
104
420
|
}
|
|
105
421
|
/**
|
|
106
422
|
* Turn hits into results a caller can read.
|
|
@@ -182,14 +498,22 @@ export class PostgresAudit {
|
|
|
182
498
|
async write(event) {
|
|
183
499
|
const [actorType, actorId] = event.actor.split(':');
|
|
184
500
|
await withOrg(this.pool, event.orgId, async (client) => {
|
|
185
|
-
await client.query(
|
|
501
|
+
await client.query(
|
|
502
|
+
// `surface` and `target` were literals in this statement — 'api' and
|
|
503
|
+
// an empty object — so every MCP call was logged as REST and the
|
|
504
|
+
// `gin (target)` index built for this indexed nothing. The columns
|
|
505
|
+
// and the schema were right from the first migration; only the write
|
|
506
|
+
// was not.
|
|
507
|
+
`INSERT INTO audit_events
|
|
186
508
|
(org_id, actor_type, actor_id, actor_label, action, surface, target, result, detail, request_id)
|
|
187
|
-
VALUES ($1,$2,$3,$4,$5
|
|
509
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10)`, [
|
|
188
510
|
event.orgId,
|
|
189
511
|
actorType ?? 'unknown',
|
|
190
512
|
/^[0-9a-f-]{36}$/i.test(actorId ?? '') ? actorId : null,
|
|
191
513
|
event.actor,
|
|
192
514
|
event.action,
|
|
515
|
+
event.surface ?? 'api',
|
|
516
|
+
JSON.stringify(event.target ?? {}),
|
|
193
517
|
event.result,
|
|
194
518
|
JSON.stringify(event.detail),
|
|
195
519
|
event.requestId,
|
|
@@ -208,18 +532,46 @@ export class HttpEmbedder {
|
|
|
208
532
|
endpoint;
|
|
209
533
|
model;
|
|
210
534
|
dimensions;
|
|
211
|
-
|
|
535
|
+
timeoutMs;
|
|
536
|
+
constructor(endpoint, model, dimensions,
|
|
537
|
+
/** One query, one vector, and a caller waiting on it. */
|
|
538
|
+
timeoutMs = 15_000) {
|
|
212
539
|
this.endpoint = endpoint;
|
|
213
540
|
this.model = model;
|
|
214
541
|
this.dimensions = dimensions;
|
|
542
|
+
this.timeoutMs = timeoutMs;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* One embedder per provider, built once and reused.
|
|
546
|
+
*
|
|
547
|
+
* Keyed on the provider's id, so an operator who edits an endpoint has to
|
|
548
|
+
* restart — which is true of every other piece of configuration here, and the
|
|
549
|
+
* alternative is a cache that has to be invalidated from a table nothing
|
|
550
|
+
* watches.
|
|
551
|
+
*/
|
|
552
|
+
static pool(timeoutMs) {
|
|
553
|
+
const embedders = new Map();
|
|
554
|
+
return (provider) => {
|
|
555
|
+
const cached = embedders.get(provider.id);
|
|
556
|
+
if (cached !== undefined)
|
|
557
|
+
return cached;
|
|
558
|
+
const embedder = new HttpEmbedder(provider.endpoint, provider.model, provider.dimensions, ...(timeoutMs === undefined ? [] : [timeoutMs]));
|
|
559
|
+
embedders.set(provider.id, embedder);
|
|
560
|
+
return embedder;
|
|
561
|
+
};
|
|
215
562
|
}
|
|
216
563
|
async embed(texts) {
|
|
217
564
|
if (texts.length === 0)
|
|
218
565
|
return [];
|
|
566
|
+
// On the search path, so much tighter than the worker's: a caller is
|
|
567
|
+
// waiting. Without any bound a wedged embedder held every search open for
|
|
568
|
+
// undici's 300 s default, which exhausts the connection pool long before
|
|
569
|
+
// anyone sees an error.
|
|
219
570
|
const response = await fetch(new URL('/embeddings', this.endpoint), {
|
|
220
571
|
method: 'POST',
|
|
221
572
|
headers: { 'content-type': 'application/json' },
|
|
222
573
|
body: JSON.stringify({ model: this.model, input: texts }),
|
|
574
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
223
575
|
});
|
|
224
576
|
if (!response.ok) {
|
|
225
577
|
throw new Error(`the embedding endpoint answered ${response.status}`);
|
|
@@ -255,8 +607,7 @@ export class NacreIngest {
|
|
|
255
607
|
}
|
|
256
608
|
/** The layers this caller may write to, by slug. */
|
|
257
609
|
async writableLayer(client, auth, layerSlug) {
|
|
258
|
-
const
|
|
259
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
610
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
260
611
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
261
612
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
262
613
|
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
@@ -277,12 +628,32 @@ export class NacreIngest {
|
|
|
277
628
|
const layerId = await this.writableLayer(client, auth, request.layer);
|
|
278
629
|
if (layerId === undefined)
|
|
279
630
|
return undefined;
|
|
280
|
-
const
|
|
281
|
-
const
|
|
631
|
+
const inline = request.content !== undefined;
|
|
632
|
+
const source = inline ? request.content : request.url;
|
|
282
633
|
const hash = createHash('sha256').update(source, 'utf8').digest('hex');
|
|
634
|
+
// Bytes to object storage before the row, never after.
|
|
635
|
+
//
|
|
636
|
+
// The reverse fails unrecoverably, which is the same argument the
|
|
637
|
+
// delete path is ordered by: a row that names an object which was never
|
|
638
|
+
// written is a document the worker fails on every attempt, forever,
|
|
639
|
+
// with the API having answered `queued`. A PUT with no row is a stray
|
|
640
|
+
// object at a deterministic key that the next ingest overwrites.
|
|
641
|
+
//
|
|
642
|
+
// Only for inline content. A `url` document is a reference already, and
|
|
643
|
+
// copying somebody else's URL into our bucket at ingest time would be
|
|
644
|
+
// a fetch on the request path — which is exactly what the worker does
|
|
645
|
+
// later, with a timeout and a sandbox.
|
|
646
|
+
let sourceType = inline ? 'inline' : 'url';
|
|
647
|
+
let sourceRef = source;
|
|
648
|
+
if (inline && this.deps.objects !== undefined) {
|
|
649
|
+
const key = documentKey(auth.orgId, layerId, request.externalId);
|
|
650
|
+
await this.deps.objects.put(key, new TextEncoder().encode(source), 'text/plain');
|
|
651
|
+
sourceType = 's3';
|
|
652
|
+
sourceRef = key;
|
|
653
|
+
}
|
|
283
654
|
const { rows } = await client.query(`INSERT INTO documents
|
|
284
|
-
(org_id, layer_id, external_id, source_type, source_ref, title, content_hash, status)
|
|
285
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')
|
|
655
|
+
(org_id, layer_id, external_id, source_type, source_ref, title, content_hash, metadata, status)
|
|
656
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending')
|
|
286
657
|
ON CONFLICT (layer_id, external_id) DO UPDATE SET
|
|
287
658
|
-- Only touch the row when the content actually changed. A repeat
|
|
288
659
|
-- with the same bytes must not re-queue work: every client that
|
|
@@ -291,7 +662,19 @@ export class NacreIngest {
|
|
|
291
662
|
THEN EXCLUDED.source_ref ELSE documents.source_ref END,
|
|
292
663
|
title = COALESCE(EXCLUDED.title, documents.title),
|
|
293
664
|
content_hash = EXCLUDED.content_hash,
|
|
665
|
+
metadata = EXCLUDED.metadata,
|
|
666
|
+
-- Metadata counts as a change, because it is written into the
|
|
667
|
+
-- vector payload of every point and a filter reads it from there.
|
|
668
|
+
-- Updating the row alone would leave the two disagreeing: the
|
|
669
|
+
-- document would carry a tag it does not answer to, which is the
|
|
670
|
+
-- silent shape of failure this repository keeps removing.
|
|
671
|
+
--
|
|
672
|
+
-- The cost is real — this re-embeds a document whose bytes did not
|
|
673
|
+
-- change — and the cheaper path is a payload-only write like the
|
|
674
|
+
-- ACL retag sweep does. Not built; docs/api.md says so rather than
|
|
675
|
+
-- letting an operator discover it from a bill.
|
|
294
676
|
status = CASE WHEN documents.content_hash IS DISTINCT FROM EXCLUDED.content_hash
|
|
677
|
+
OR documents.metadata IS DISTINCT FROM EXCLUDED.metadata
|
|
295
678
|
THEN 'pending' ELSE documents.status END,
|
|
296
679
|
deleted_at = NULL,
|
|
297
680
|
updated_at = now()
|
|
@@ -300,9 +683,10 @@ export class NacreIngest {
|
|
|
300
683
|
layerId,
|
|
301
684
|
request.externalId,
|
|
302
685
|
sourceType,
|
|
303
|
-
|
|
686
|
+
sourceRef,
|
|
304
687
|
request.title ?? null,
|
|
305
688
|
`sha256:${hash}`,
|
|
689
|
+
JSON.stringify(request.metadata),
|
|
306
690
|
]);
|
|
307
691
|
const row = rows[0];
|
|
308
692
|
if (row === undefined)
|
|
@@ -317,15 +701,16 @@ export class NacreIngest {
|
|
|
317
701
|
async remove(auth, documentId) {
|
|
318
702
|
if (!/^[0-9a-f-]{36}$/i.test(documentId))
|
|
319
703
|
return false;
|
|
320
|
-
const slug = await this.deps.orgSlug(auth.orgId);
|
|
321
|
-
// The token names an organization that is not there. Refusing rather than
|
|
322
|
-
// deleting only in Postgres: a tombstone we cannot mirror into the index is
|
|
323
|
-
// a document that stays searchable while the API reports it gone.
|
|
324
|
-
if (slug === undefined)
|
|
325
|
-
return false;
|
|
326
704
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
327
|
-
|
|
328
|
-
|
|
705
|
+
// The collection, from the column that owns it, inside the same
|
|
706
|
+
// transaction as the permission check. Refusing rather than deleting
|
|
707
|
+
// only in Postgres: a tombstone we cannot mirror into the index is a
|
|
708
|
+
// document that stays searchable while the API reports it gone.
|
|
709
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
710
|
+
const collection = orgs[0]?.vector_collection;
|
|
711
|
+
if (collection === undefined)
|
|
712
|
+
return false;
|
|
713
|
+
const principals = await principalsFor(client, auth, this.deps.principalsCache);
|
|
329
714
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
330
715
|
const tree = await loadScopeTree(client, auth.orgId, [documentId]);
|
|
331
716
|
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
|
|
@@ -363,7 +748,7 @@ export class NacreIngest {
|
|
|
363
748
|
// which purges rather than repairs. Index-first fails the other way: the
|
|
364
749
|
// points are marked, the row is not, the caller sees an error and
|
|
365
750
|
// retries, and in the meantime the document is already invisible.
|
|
366
|
-
await this.deps.tombstone.tombstone(
|
|
751
|
+
await this.deps.tombstone.tombstone(collection, documentId);
|
|
367
752
|
// A tombstone, not a delete, on this side too. Physical removal of the
|
|
368
753
|
// points is the collector's job, and `deleted_at` is what puts the
|
|
369
754
|
// document in its queue.
|
|
@@ -384,9 +769,13 @@ export class NacreIngest {
|
|
|
384
769
|
export class PostgresJobs {
|
|
385
770
|
pool;
|
|
386
771
|
role;
|
|
387
|
-
|
|
772
|
+
principalsCache;
|
|
773
|
+
constructor(pool, role,
|
|
774
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
775
|
+
principalsCache) {
|
|
388
776
|
this.pool = pool;
|
|
389
777
|
this.role = role;
|
|
778
|
+
this.principalsCache = principalsCache;
|
|
390
779
|
}
|
|
391
780
|
async read(auth, jobId) {
|
|
392
781
|
const orgId = auth.orgId;
|
|
@@ -397,7 +786,7 @@ export class PostgresJobs {
|
|
|
397
786
|
// different projection — and needs the same check. The status and the
|
|
398
787
|
// error string are both facts about a document the caller may not be
|
|
399
788
|
// allowed to know exists.
|
|
400
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
789
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
401
790
|
if (plan.kind === 'none')
|
|
402
791
|
return undefined;
|
|
403
792
|
const { rows } = await client.query(`SELECT id, status, error, chunk_count, layer_id FROM documents
|
|
@@ -430,20 +819,52 @@ export class PostgresJobs {
|
|
|
430
819
|
}, this.role === undefined ? {} : { role: this.role });
|
|
431
820
|
}
|
|
432
821
|
}
|
|
822
|
+
async function principalsFor(client, auth, cache) {
|
|
823
|
+
if (cache === undefined) {
|
|
824
|
+
return effectivePrincipals(auth.principal, await PostgresGroupGraph.load(client, auth.orgId));
|
|
825
|
+
}
|
|
826
|
+
// In the same transaction as the grants that follow, so the key and the grant
|
|
827
|
+
// set describe one instant. Read separately, there is a window where the
|
|
828
|
+
// version says "fresh" about a set loaded before a change.
|
|
829
|
+
const groupsVersion = await loadGroupsVersion(client, auth.orgId);
|
|
830
|
+
return cachedEffectivePrincipals({
|
|
831
|
+
orgId: auth.orgId,
|
|
832
|
+
principal: auth.principal,
|
|
833
|
+
groupsVersion,
|
|
834
|
+
ttlSeconds: cache.ttlSeconds,
|
|
835
|
+
}, cache.store, () => PostgresGroupGraph.load(client, auth.orgId));
|
|
836
|
+
}
|
|
433
837
|
/** The per-request permission context, loaded once and asked several questions. */
|
|
434
|
-
async function contextFor(client, auth) {
|
|
435
|
-
const
|
|
436
|
-
const principals = effectivePrincipals(auth.principal, graph);
|
|
838
|
+
async function contextFor(client, auth, cache) {
|
|
839
|
+
const principals = await principalsFor(client, auth, cache);
|
|
437
840
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
438
841
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
439
842
|
return { orgId: auth.orgId, role: auth.role, principals, grants, tree };
|
|
440
843
|
}
|
|
441
844
|
export class PostgresLayers {
|
|
442
845
|
pool;
|
|
846
|
+
vectors;
|
|
443
847
|
role;
|
|
444
|
-
|
|
848
|
+
principalsCache;
|
|
849
|
+
constructor(pool,
|
|
850
|
+
/**
|
|
851
|
+
* Only to ask which named vectors the organization's collection has.
|
|
852
|
+
*
|
|
853
|
+
* A layer is created against a provider, and the provider decides which
|
|
854
|
+
* named vector the layer's points go into. Qdrant fixes that set when the
|
|
855
|
+
* collection is created and has no way to extend it, so a layer on a
|
|
856
|
+
* provider the collection has no slot for is a layer whose every document
|
|
857
|
+
* fails in the worker with `Not existing vector name` — after the API has
|
|
858
|
+
* answered 202 and the row says `pending`. Checking here is what turns that
|
|
859
|
+
* into a collection rebuild instead.
|
|
860
|
+
*/
|
|
861
|
+
vectors, role,
|
|
862
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
863
|
+
principalsCache) {
|
|
445
864
|
this.pool = pool;
|
|
865
|
+
this.vectors = vectors;
|
|
446
866
|
this.role = role;
|
|
867
|
+
this.principalsCache = principalsCache;
|
|
447
868
|
}
|
|
448
869
|
get scope() {
|
|
449
870
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -456,11 +877,11 @@ export class PostgresLayers {
|
|
|
456
877
|
* break invariant I2 — but writing it the other way keeps one habit for both
|
|
457
878
|
* and removes the question of which endpoints are allowed to be sloppy.
|
|
458
879
|
*/
|
|
459
|
-
async list(auth) {
|
|
880
|
+
async list(auth, page) {
|
|
460
881
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
461
|
-
const plan = resolve(await contextFor(client, auth), 'read');
|
|
882
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
462
883
|
if (plan.kind === 'none')
|
|
463
|
-
return [];
|
|
884
|
+
return { items: [], nextCursor: null };
|
|
464
885
|
// The count comes from the same statement. It is what a catalog is
|
|
465
886
|
// for — an agent choosing where to search, or a person deciding
|
|
466
887
|
// whether a layer is worth opening — and the MCP catalog has always
|
|
@@ -472,30 +893,45 @@ export class PostgresLayers {
|
|
|
472
893
|
// grants exist — a commercial module — this becomes a number that
|
|
473
894
|
// discloses more than the caller can reach, and it has to be recomputed
|
|
474
895
|
// per caller or dropped.
|
|
475
|
-
const projection = `l.id, l.slug, l.name, l.workspace_id, l.description,
|
|
896
|
+
const projection = `l.id, l.slug, l.name, l.workspace_id, l.description, l.created_at,
|
|
476
897
|
(SELECT count(*) FROM documents d
|
|
477
898
|
WHERE d.layer_id = l.id AND d.deleted_at IS NULL) AS document_count`;
|
|
899
|
+
// Ordered by (created_at, id) rather than by slug, because that is the
|
|
900
|
+
// cursor's sort key and a page has to be stable under inserts. Sorting
|
|
901
|
+
// by a mutable column would move rows between pages when one is renamed.
|
|
902
|
+
const order = 'ORDER BY l.created_at, l.id';
|
|
903
|
+
const after = page?.after;
|
|
904
|
+
const seek = after === undefined ? '' : ' AND (l.created_at, l.id) > ($3::timestamptz, $4::uuid)';
|
|
905
|
+
const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
|
|
478
906
|
const { rows } = plan.kind === 'all'
|
|
479
907
|
? await client.query(`SELECT ${projection} FROM layers l
|
|
480
|
-
WHERE l.org_id = $1 AND l.deleted_at IS NULL
|
|
908
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL${seek.replace('$3', '$2').replace('$4', '$3')}
|
|
909
|
+
${order}${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id])
|
|
481
910
|
: await client.query(`SELECT ${projection} FROM layers l
|
|
482
|
-
WHERE l.org_id = $1 AND l.deleted_at IS NULL AND l.id = ANY($2::uuid[])
|
|
483
|
-
|
|
484
|
-
|
|
911
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL AND l.id = ANY($2::uuid[])${seek}
|
|
912
|
+
${order}${cap}`, after === undefined
|
|
913
|
+
? [auth.orgId, [...plan.layers]]
|
|
914
|
+
: [auth.orgId, [...plan.layers], after.createdAt, after.id]);
|
|
915
|
+
const layers = rows.map((r) => ({
|
|
485
916
|
id: r.id,
|
|
486
917
|
slug: r.slug,
|
|
487
918
|
name: r.name,
|
|
488
919
|
workspaceId: r.workspace_id,
|
|
489
920
|
description: r.description ?? '',
|
|
490
921
|
documentCount: Number(r.document_count),
|
|
922
|
+
createdAt: r.created_at.toISOString(),
|
|
491
923
|
}));
|
|
924
|
+
return pageOf(layers, page, (l) => ({ createdAt: l.createdAt, id: l.id }));
|
|
492
925
|
}, this.scope);
|
|
493
926
|
}
|
|
927
|
+
async update(auth, layerId, input) {
|
|
928
|
+
return updateLayer(this.pool, auth, layerId, input, this.role, this.principalsCache);
|
|
929
|
+
}
|
|
494
930
|
async create(auth, input) {
|
|
495
931
|
if (!/^[0-9a-f-]{36}$/i.test(input.workspaceId))
|
|
496
932
|
return { kind: 'denied' };
|
|
497
933
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
498
|
-
const context = await contextFor(client, auth);
|
|
934
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
499
935
|
// `referenceAllows` and not `resolve`: the question is "may they
|
|
500
936
|
// administer this one workspace", which is what the reference answers
|
|
501
937
|
// directly. Reading it out of a flattened plan would mean inferring a
|
|
@@ -509,9 +945,35 @@ export class PostgresLayers {
|
|
|
509
945
|
const { rows: workspaces } = await client.query(`SELECT id FROM workspaces WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, input.workspaceId]);
|
|
510
946
|
if (workspaces[0] === undefined)
|
|
511
947
|
return { kind: 'denied' };
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
948
|
+
// The caller's choice, or the organization's if there is no ambiguity.
|
|
949
|
+
//
|
|
950
|
+
// `ORDER BY org_id NULLS LAST LIMIT 1` was the whole of this, which is
|
|
951
|
+
// fine while an organization has one provider and arbitrary the moment
|
|
952
|
+
// it has two — two rows with the same non-null `org_id` come back in
|
|
953
|
+
// whatever order the database felt like, so the model a layer was
|
|
954
|
+
// created against was a coin toss with no way for the caller to call
|
|
955
|
+
// it. Now they can, and where they do not, an ambiguous choice is
|
|
956
|
+
// refused rather than guessed.
|
|
957
|
+
const { rows: providers } = await client.query(`SELECT id, model, dimensions, (org_id IS NOT NULL) AS own
|
|
958
|
+
FROM embedding_providers
|
|
959
|
+
WHERE (org_id = $1 OR org_id IS NULL)
|
|
960
|
+
AND ($2::uuid IS NULL OR id = $2::uuid)
|
|
961
|
+
ORDER BY org_id NULLS LAST, id`, [auth.orgId, input.providerId ?? null]);
|
|
962
|
+
if (input.providerId !== undefined && providers[0] === undefined) {
|
|
963
|
+
return {
|
|
964
|
+
kind: 'provider',
|
|
965
|
+
detail: 'No embedding provider with that id is available to this organization.',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
const own = providers.filter((p) => p.own);
|
|
969
|
+
if (input.providerId === undefined && own.length > 1) {
|
|
970
|
+
return {
|
|
971
|
+
kind: 'provider',
|
|
972
|
+
detail: `This organization has ${own.length} embedding providers, so which one a layer ` +
|
|
973
|
+
"is indexed with is not implied. Name it with 'provider_id'.",
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const provider = own[0] ?? providers[0];
|
|
515
977
|
if (provider === undefined) {
|
|
516
978
|
throw new Error('no embedding provider is configured; a layer cannot be created without one');
|
|
517
979
|
}
|
|
@@ -521,10 +983,46 @@ export class PostgresLayers {
|
|
|
521
983
|
// collection does not have, which fails every upsert with `Bad
|
|
522
984
|
// Request` and would return nothing even if it did not.
|
|
523
985
|
const vector = vectorName(provider.model, provider.dimensions);
|
|
524
|
-
|
|
525
|
-
|
|
986
|
+
// Does the collection have room for this model?
|
|
987
|
+
//
|
|
988
|
+
// Usually yes: `init` creates the collection and the global provider
|
|
989
|
+
// from the same configuration, so every layer lands on the slot that is
|
|
990
|
+
// already there. An operator who adds a second provider is the case
|
|
991
|
+
// this exists for — `embedding_providers` has carried an `org_id` and a
|
|
992
|
+
// documented "NULL = global default" since the first migration, and a
|
|
993
|
+
// layer created against an organization's own provider used to accept
|
|
994
|
+
// documents and fail every one of them, forever, with the API reporting
|
|
995
|
+
// `queued` throughout.
|
|
996
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
997
|
+
const collection = orgs[0]?.vector_collection;
|
|
998
|
+
if (collection === undefined)
|
|
999
|
+
return { kind: 'denied' };
|
|
1000
|
+
const present = await this.vectors.vectorsOf(collection);
|
|
1001
|
+
// A copy in flight if the slot is missing. The layer is created either
|
|
1002
|
+
// way — it is a real layer with a real provider — and the worker holds
|
|
1003
|
+
// its documents at `pending` until the collection that can hold them
|
|
1004
|
+
// exists. See `claimNext`.
|
|
1005
|
+
//
|
|
1006
|
+
// Reusing the reindex's own state rather than inventing a second
|
|
1007
|
+
// mechanism: this *is* the copy phase of a reindex, with no documents
|
|
1008
|
+
// to re-embed afterwards, so `finishCopy` and `finishReindexIfDone`
|
|
1009
|
+
// close it out with nothing added.
|
|
1010
|
+
const state = vector in present
|
|
1011
|
+
? null
|
|
1012
|
+
: toStateJson({
|
|
1013
|
+
status: 'running',
|
|
1014
|
+
phase: 'copying',
|
|
1015
|
+
shadowVector: vector,
|
|
1016
|
+
providerId: provider.id,
|
|
1017
|
+
startedAt: new Date().toISOString(),
|
|
1018
|
+
total: 0,
|
|
1019
|
+
done: 0,
|
|
1020
|
+
failed: 0,
|
|
1021
|
+
});
|
|
1022
|
+
const { rows } = await client.query(`INSERT INTO layers (org_id, workspace_id, slug, name, provider_id, vector_name, reindex_state)
|
|
1023
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
526
1024
|
ON CONFLICT DO NOTHING
|
|
527
|
-
RETURNING id, slug, name, workspace_id, description`, [auth.orgId, input.workspaceId, input.slug, input.name, provider.id, vector]);
|
|
1025
|
+
RETURNING id, slug, name, workspace_id, description, created_at`, [auth.orgId, input.workspaceId, input.slug, input.name, provider.id, vector, state]);
|
|
528
1026
|
const row = rows[0];
|
|
529
1027
|
// ON CONFLICT DO NOTHING returns nothing, and by here the caller has
|
|
530
1028
|
// already proved admin on the workspace — so the only way to get here
|
|
@@ -541,17 +1039,189 @@ export class PostgresLayers {
|
|
|
541
1039
|
description: row.description ?? '',
|
|
542
1040
|
// Just created, so this is a fact rather than a query.
|
|
543
1041
|
documentCount: 0,
|
|
1042
|
+
createdAt: row.created_at.toISOString(),
|
|
1043
|
+
},
|
|
1044
|
+
};
|
|
1045
|
+
}, this.scope);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Workspaces.
|
|
1050
|
+
*
|
|
1051
|
+
* The missing link in the API's own flow: creating a layer takes a
|
|
1052
|
+
* `workspace_id`, and until this existed the only way to have one was the line
|
|
1053
|
+
* `init` printed. `docs/quickstart.md` said as much — "the workspace id is the
|
|
1054
|
+
* one `init` printed" — so a second administrator, a client library, or anyone
|
|
1055
|
+
* who closed that terminal had no way forward that did not involve reading the
|
|
1056
|
+
* database directly.
|
|
1057
|
+
*/
|
|
1058
|
+
export class PostgresWorkspaces {
|
|
1059
|
+
pool;
|
|
1060
|
+
role;
|
|
1061
|
+
principalsCache;
|
|
1062
|
+
constructor(pool, role,
|
|
1063
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1064
|
+
principalsCache) {
|
|
1065
|
+
this.pool = pool;
|
|
1066
|
+
this.role = role;
|
|
1067
|
+
this.principalsCache = principalsCache;
|
|
1068
|
+
}
|
|
1069
|
+
get scope() {
|
|
1070
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* The workspaces this caller can reach, newest last, cursor-paged.
|
|
1074
|
+
*
|
|
1075
|
+
* Two ways to reach one, and the second is the reason this endpoint had to
|
|
1076
|
+
* exist at all:
|
|
1077
|
+
*
|
|
1078
|
+
* 1. it holds a layer the read plan reaches — the ordinary case, and the same
|
|
1079
|
+
* narrowing the layer listing does;
|
|
1080
|
+
* 2. the caller holds a grant on the workspace itself. An **empty** workspace
|
|
1081
|
+
* has no layers to be reached through, so without this an administrator
|
|
1082
|
+
* who had just been granted one could not see it, and therefore could not
|
|
1083
|
+
* create the first layer in it. That is the exact deadlock this endpoint
|
|
1084
|
+
* is here to break.
|
|
1085
|
+
*
|
|
1086
|
+
* Filtered in memory rather than in SQL because the second rule is the
|
|
1087
|
+
* resolver's to answer, not a join's, and an organization has tens of
|
|
1088
|
+
* workspaces rather than thousands. The listing is narrowed before it is
|
|
1089
|
+
* paged — a page assembled first and filtered second would leak the count.
|
|
1090
|
+
*/
|
|
1091
|
+
async list(auth, page) {
|
|
1092
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1093
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
1094
|
+
// No early return on `plan.kind === 'none'`, and that is the whole
|
|
1095
|
+
// point of this endpoint.
|
|
1096
|
+
//
|
|
1097
|
+
// `resolve` flattens a grant set to the layers it reaches, so a
|
|
1098
|
+
// principal whose only grant is `admin` on an **empty** workspace
|
|
1099
|
+
// resolves to `none` — there are no layers for it to name. Bailing out
|
|
1100
|
+
// here left exactly that caller unable to list the workspace they
|
|
1101
|
+
// administer, and therefore unable to create the first layer in it,
|
|
1102
|
+
// which is the deadlock this endpoint was added to break. It was
|
|
1103
|
+
// written that way first and caught by running it.
|
|
1104
|
+
const plan = resolve(context, 'read');
|
|
1105
|
+
const { rows } = await client.query(`SELECT w.id, w.slug, w.name, w.created_at,
|
|
1106
|
+
(SELECT array_agg(l.id) FROM layers l
|
|
1107
|
+
WHERE l.workspace_id = w.id AND l.deleted_at IS NULL) AS layer_ids
|
|
1108
|
+
FROM workspaces w
|
|
1109
|
+
WHERE w.org_id = $1 AND w.deleted_at IS NULL
|
|
1110
|
+
ORDER BY w.created_at, w.id`, [auth.orgId]);
|
|
1111
|
+
const reachable = rows.filter((w) => {
|
|
1112
|
+
if (plan.kind === 'all')
|
|
1113
|
+
return true;
|
|
1114
|
+
if (plan.kind === 'scoped' &&
|
|
1115
|
+
(w.layer_ids ?? []).some((id) => plan.layers.includes(id))) {
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
// The grant on the workspace itself, which `admin` satisfies for
|
|
1119
|
+
// `read` — invariant 6's other half, and the only thing that answers
|
|
1120
|
+
// for a workspace with nothing in it yet.
|
|
1121
|
+
return referenceAllows(context, { type: 'workspace', id: w.id }, 'read');
|
|
1122
|
+
});
|
|
1123
|
+
const items = reachable.map((w) => ({
|
|
1124
|
+
id: w.id,
|
|
1125
|
+
slug: w.slug,
|
|
1126
|
+
name: w.name,
|
|
1127
|
+
layerCount: (w.layer_ids ?? []).length,
|
|
1128
|
+
createdAt: w.created_at.toISOString(),
|
|
1129
|
+
}));
|
|
1130
|
+
return pageOf(items, page, (w) => ({ createdAt: w.createdAt, id: w.id }));
|
|
1131
|
+
}, this.scope);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Create a workspace. `org_admin` only.
|
|
1135
|
+
*
|
|
1136
|
+
* There is no scope above a workspace to hold a grant on — `scope_type` is
|
|
1137
|
+
* one of workspace, layer, document — so there is no one to ask except the
|
|
1138
|
+
* role. `referenceAllows` would answer this with `role === 'org_admin'` and
|
|
1139
|
+
* nothing else, so the check is written plainly rather than routed through a
|
|
1140
|
+
* resolver call that can only return one thing.
|
|
1141
|
+
*
|
|
1142
|
+
* `platform_admin` is refused along with everyone else. Rule 2: it
|
|
1143
|
+
* administers organizations and reads no documents, and a workspace is where
|
|
1144
|
+
* documents live.
|
|
1145
|
+
*/
|
|
1146
|
+
async create(auth, input) {
|
|
1147
|
+
if (auth.role !== 'org_admin')
|
|
1148
|
+
return { kind: 'denied' };
|
|
1149
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1150
|
+
const { rows } = await client.query(`INSERT INTO workspaces (org_id, slug, name) VALUES ($1,$2,$3)
|
|
1151
|
+
ON CONFLICT DO NOTHING
|
|
1152
|
+
RETURNING id, slug, name, created_at`, [auth.orgId, input.slug, input.name]);
|
|
1153
|
+
const row = rows[0];
|
|
1154
|
+
// ON CONFLICT DO NOTHING returns nothing, and by here the caller is
|
|
1155
|
+
// org_admin — so the only way to get here is a slug already in use.
|
|
1156
|
+
// 409 rather than 404, as on layer create: the caller has proved they
|
|
1157
|
+
// administer the organization, so this is a fact about the resource
|
|
1158
|
+
// rather than about what they can see.
|
|
1159
|
+
if (row === undefined)
|
|
1160
|
+
return { kind: 'conflict' };
|
|
1161
|
+
return {
|
|
1162
|
+
kind: 'created',
|
|
1163
|
+
workspace: {
|
|
1164
|
+
id: row.id,
|
|
1165
|
+
slug: row.slug,
|
|
1166
|
+
name: row.name,
|
|
1167
|
+
layerCount: 0,
|
|
1168
|
+
createdAt: row.created_at.toISOString(),
|
|
544
1169
|
},
|
|
545
1170
|
};
|
|
546
1171
|
}, this.scope);
|
|
547
1172
|
}
|
|
548
1173
|
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Rename a layer, or change what it says it is for.
|
|
1176
|
+
*
|
|
1177
|
+
* `admin` on the workspace, which is what creating one takes — the same
|
|
1178
|
+
* question asked of the same resolver, so the two cannot drift into "who may
|
|
1179
|
+
* create" and "who may rename" meaning different things.
|
|
1180
|
+
*
|
|
1181
|
+
* The description is user-facing copy: it is what the MCP tool's generated
|
|
1182
|
+
* description is built from, so an agent's picture of what a layer holds
|
|
1183
|
+
* changes with it. That is the reason it is editable at all.
|
|
1184
|
+
*/
|
|
1185
|
+
async function updateLayer(pool, auth, layerId, input, role, principalsCache) {
|
|
1186
|
+
if (!/^[0-9a-f-]{36}$/i.test(layerId))
|
|
1187
|
+
return false;
|
|
1188
|
+
if (input.name === undefined && input.description === undefined)
|
|
1189
|
+
return false;
|
|
1190
|
+
return withOrg(pool, auth.orgId, async (client) => {
|
|
1191
|
+
const context = await contextFor(client, auth, principalsCache);
|
|
1192
|
+
// The layer's workspace, before anything is decided — the grant is on the
|
|
1193
|
+
// workspace, so there is nothing to ask until we know which one.
|
|
1194
|
+
const { rows } = await client.query(`SELECT workspace_id FROM layers
|
|
1195
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, layerId]);
|
|
1196
|
+
const workspaceId = rows[0]?.workspace_id;
|
|
1197
|
+
if (workspaceId === undefined)
|
|
1198
|
+
return false;
|
|
1199
|
+
// `referenceAllows` on the workspace rather than a flattened plan, for
|
|
1200
|
+
// the reason layer creation gives: the question is "may this caller
|
|
1201
|
+
// administer this workspace", which the reference answers directly, and
|
|
1202
|
+
// inferring it from the layers a plan happens to contain gets an empty
|
|
1203
|
+
// workspace wrong.
|
|
1204
|
+
if (!referenceAllows(context, { type: 'workspace', id: workspaceId }, 'admin'))
|
|
1205
|
+
return false;
|
|
1206
|
+
// COALESCE, so an absent field is left alone rather than blanked. PATCH
|
|
1207
|
+
// changes what it names; a missing key is not an instruction to erase.
|
|
1208
|
+
await client.query(`UPDATE layers
|
|
1209
|
+
SET name = COALESCE($3, name),
|
|
1210
|
+
description = COALESCE($4, description)
|
|
1211
|
+
WHERE org_id = $1 AND id = $2`, [auth.orgId, layerId, input.name ?? null, input.description ?? null]);
|
|
1212
|
+
return true;
|
|
1213
|
+
}, role === undefined ? {} : { role });
|
|
1214
|
+
}
|
|
549
1215
|
export class PostgresGrants {
|
|
550
1216
|
pool;
|
|
551
1217
|
role;
|
|
552
|
-
|
|
1218
|
+
principalsCache;
|
|
1219
|
+
constructor(pool, role,
|
|
1220
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1221
|
+
principalsCache) {
|
|
553
1222
|
this.pool = pool;
|
|
554
1223
|
this.role = role;
|
|
1224
|
+
this.principalsCache = principalsCache;
|
|
555
1225
|
}
|
|
556
1226
|
get scope() {
|
|
557
1227
|
return this.role === undefined ? {} : { role: this.role };
|
|
@@ -564,15 +1234,32 @@ export class PostgresGrants {
|
|
|
564
1234
|
* than none — an administrator who cannot see an existing grant revokes the
|
|
565
1235
|
* wrong thing.
|
|
566
1236
|
*/
|
|
567
|
-
|
|
1237
|
+
/**
|
|
1238
|
+
* Paginated, and the page may be short.
|
|
1239
|
+
*
|
|
1240
|
+
* Which grants a caller may see is decided by `referenceAllows` against the
|
|
1241
|
+
* scope tree, which SQL cannot express — so rows are fetched by cursor and
|
|
1242
|
+
* filtered afterwards. That makes `items.length < limit` normal rather than a
|
|
1243
|
+
* signal, and `next_cursor` the only thing that says whether more exist.
|
|
1244
|
+
*
|
|
1245
|
+
* The cursor comes from the last row **fetched**, not the last returned.
|
|
1246
|
+
* Deriving it from the last returned row would skip everything the filter
|
|
1247
|
+
* removed after it, which is a page of grants silently missing from an
|
|
1248
|
+
* administrator's view of who can reach what.
|
|
1249
|
+
*/
|
|
1250
|
+
async list(auth, page) {
|
|
568
1251
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
569
|
-
const context = await contextFor(client, auth);
|
|
1252
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
570
1253
|
const plan = resolve(context, 'admin');
|
|
571
1254
|
if (plan.kind === 'none')
|
|
572
|
-
return [];
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
1255
|
+
return { items: [], nextCursor: null };
|
|
1256
|
+
const after = page?.after;
|
|
1257
|
+
const seek = after === undefined ? '' : ' AND (created_at, id) > ($2::timestamptz, $3::uuid)';
|
|
1258
|
+
const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
|
|
1259
|
+
const { rows } = await client.query(`SELECT id, principal_type, principal_id, scope_type, scope_id, permission, effect, source,
|
|
1260
|
+
created_at
|
|
1261
|
+
FROM grants WHERE org_id = $1${seek} ORDER BY created_at, id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
|
|
1262
|
+
const visible = rows
|
|
576
1263
|
.filter((r) => referenceAllows(context, { type: r.scope_type, id: r.scope_id }, 'admin'))
|
|
577
1264
|
.map((r) => ({
|
|
578
1265
|
id: r.id,
|
|
@@ -584,6 +1271,15 @@ export class PostgresGrants {
|
|
|
584
1271
|
effect: r.effect,
|
|
585
1272
|
source: r.source,
|
|
586
1273
|
}));
|
|
1274
|
+
// From the last row fetched rather than the last returned. The filter
|
|
1275
|
+
// above removes rows the caller may not administer, and taking the
|
|
1276
|
+
// cursor from a survivor would skip every row the filter dropped after
|
|
1277
|
+
// it — a page of grants silently missing from an administrator's view.
|
|
1278
|
+
const lastFetched = rows[rows.length - 1];
|
|
1279
|
+
const nextCursor = page !== undefined && rows.length >= page.limit && lastFetched !== undefined
|
|
1280
|
+
? encodeCursor({ createdAt: lastFetched.created_at.toISOString(), id: lastFetched.id })
|
|
1281
|
+
: null;
|
|
1282
|
+
return { items: visible, nextCursor };
|
|
587
1283
|
}, this.scope);
|
|
588
1284
|
}
|
|
589
1285
|
async issue(auth, input) {
|
|
@@ -591,7 +1287,7 @@ export class PostgresGrants {
|
|
|
591
1287
|
return undefined;
|
|
592
1288
|
}
|
|
593
1289
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
594
|
-
const context = await contextFor(client, auth);
|
|
1290
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
595
1291
|
// Admin on the scope being granted, not admin in general. Otherwise
|
|
596
1292
|
// anyone holding admin on one layer could grant themselves another.
|
|
597
1293
|
if (!referenceAllows(context, { type: input.scopeType, id: input.scopeId }, 'admin')) {
|
|
@@ -650,7 +1346,7 @@ export class PostgresGrants {
|
|
|
650
1346
|
// because the object is a grant rather than a document.
|
|
651
1347
|
if (row === undefined)
|
|
652
1348
|
return false;
|
|
653
|
-
const context = await contextFor(client, auth);
|
|
1349
|
+
const context = await contextFor(client, auth, this.principalsCache);
|
|
654
1350
|
const scope = {
|
|
655
1351
|
type: row.scope_type,
|
|
656
1352
|
id: row.scope_id,
|
|
@@ -669,4 +1365,328 @@ export class PostgresGrants {
|
|
|
669
1365
|
}, this.scope);
|
|
670
1366
|
}
|
|
671
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Reading the access log back.
|
|
1370
|
+
*
|
|
1371
|
+
* Scoped through `withOrg` like every other tenant read, so the row-level
|
|
1372
|
+
* policy on `audit_events` is the second line of defence behind the role check
|
|
1373
|
+
* in the handler. `SELECT` is the only grant the application role has on this
|
|
1374
|
+
* table and that stays true — nothing here writes.
|
|
1375
|
+
*
|
|
1376
|
+
* ## Newest first, and what that does to the cursor
|
|
1377
|
+
*
|
|
1378
|
+
* Every other paged collection here reads oldest-first, because a catalog is a
|
|
1379
|
+
* set and the order is arbitrary. A log is not: the useful end is the recent
|
|
1380
|
+
* one, and an auditor paging forward from the beginning of a 400-day retention
|
|
1381
|
+
* window to find yesterday is not a feature. So this orders
|
|
1382
|
+
* `(occurred_at, id) DESC` and the seek predicate is `<` rather than `>`.
|
|
1383
|
+
*
|
|
1384
|
+
* The cursor still means "everything after the last row you saw", which is the
|
|
1385
|
+
* same contract read in the other direction. It is worth being explicit because
|
|
1386
|
+
* copying the seek clause from the layers adapter — where it is `>` — would
|
|
1387
|
+
* produce a first page that works and a second page that silently returns the
|
|
1388
|
+
* beginning of the log.
|
|
1389
|
+
*/
|
|
1390
|
+
export class PostgresAuditReader {
|
|
1391
|
+
pool;
|
|
1392
|
+
role;
|
|
1393
|
+
principalsCache;
|
|
1394
|
+
constructor(pool, role,
|
|
1395
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1396
|
+
principalsCache) {
|
|
1397
|
+
this.pool = pool;
|
|
1398
|
+
this.role = role;
|
|
1399
|
+
this.principalsCache = principalsCache;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Actions that record a substantive access to a document's contents.
|
|
1403
|
+
*
|
|
1404
|
+
* Withheld from `platform_admin`, per rule 2 in docs/authz.md as
|
|
1405
|
+
* docs/audit.md applies it to the journal. Named as a deny-list rather than
|
|
1406
|
+
* an allow-list of administrative actions, and that is the uncomfortable
|
|
1407
|
+
* direction: a new action defaults to *visible* to a platform administrator
|
|
1408
|
+
* rather than hidden.
|
|
1409
|
+
*
|
|
1410
|
+
* Chosen anyway, because the alternative fails worse. An allow-list means a
|
|
1411
|
+
* new administrative action is invisible to the operator who administers the
|
|
1412
|
+
* installation until someone remembers to add it — a silent gap in an
|
|
1413
|
+
* operational tool. This way a new *access* action is visible until someone
|
|
1414
|
+
* adds it here, which is a disclosure to an already highly-privileged role
|
|
1415
|
+
* within one installation. Both are bugs; only one of them is quiet.
|
|
1416
|
+
*
|
|
1417
|
+
* The `audit-event` skill's checklist is where this list is kept in step.
|
|
1418
|
+
*/
|
|
1419
|
+
static DOCUMENT_ACCESS = ['search', 'document.read', 'document.get', 'chunk.read'];
|
|
1420
|
+
async read(auth, query, page) {
|
|
1421
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1422
|
+
const where = ['org_id = $1'];
|
|
1423
|
+
const params = [auth.orgId];
|
|
1424
|
+
const bind = (value) => {
|
|
1425
|
+
params.push(value);
|
|
1426
|
+
return `$${params.length}`;
|
|
1427
|
+
};
|
|
1428
|
+
if (query.from !== undefined)
|
|
1429
|
+
where.push(`occurred_at >= ${bind(query.from)}::timestamptz`);
|
|
1430
|
+
if (query.to !== undefined)
|
|
1431
|
+
where.push(`occurred_at < ${bind(query.to)}::timestamptz`);
|
|
1432
|
+
if (query.actorId !== undefined)
|
|
1433
|
+
where.push(`actor_id = ${bind(query.actorId)}::uuid`);
|
|
1434
|
+
if (query.action !== undefined)
|
|
1435
|
+
where.push(`action = ${bind(query.action)}`);
|
|
1436
|
+
if (query.result !== undefined)
|
|
1437
|
+
where.push(`result = ${bind(query.result)}`);
|
|
1438
|
+
if (query.administrativeOnly === true) {
|
|
1439
|
+
where.push(`action <> ALL(${bind(PostgresAuditReader.DOCUMENT_ACCESS)}::text[])`);
|
|
1440
|
+
}
|
|
1441
|
+
// Descending, and `<` to match. See the note on the class.
|
|
1442
|
+
if (page.after !== undefined) {
|
|
1443
|
+
where.push(`(occurred_at, id) < (${bind(page.after.createdAt)}::timestamptz, ${bind(page.after.id)}::bigint)`);
|
|
1444
|
+
}
|
|
1445
|
+
// One more than asked for, so "is there another page" is answered
|
|
1446
|
+
// without a second query and without a count — a count over a
|
|
1447
|
+
// retention window is the expensive thing this endpoint must not do on
|
|
1448
|
+
// every request.
|
|
1449
|
+
const { rows } = await client.query(`SELECT id::text, occurred_at, actor_type, actor_id, actor_label, action,
|
|
1450
|
+
surface, client, target, result, detail, request_id
|
|
1451
|
+
FROM audit_events
|
|
1452
|
+
WHERE ${where.join(' AND ')}
|
|
1453
|
+
ORDER BY occurred_at DESC, id DESC
|
|
1454
|
+
LIMIT ${page.limit + 1}`, params);
|
|
1455
|
+
const items = rows.slice(0, page.limit).map((r) => ({
|
|
1456
|
+
id: r.id,
|
|
1457
|
+
occurredAt: r.occurred_at.toISOString(),
|
|
1458
|
+
actorType: r.actor_type,
|
|
1459
|
+
actorId: r.actor_id,
|
|
1460
|
+
actorLabel: r.actor_label,
|
|
1461
|
+
action: r.action,
|
|
1462
|
+
surface: r.surface,
|
|
1463
|
+
client: r.client,
|
|
1464
|
+
target: r.target ?? {},
|
|
1465
|
+
result: r.result,
|
|
1466
|
+
detail: r.detail ?? {},
|
|
1467
|
+
requestId: r.request_id,
|
|
1468
|
+
}));
|
|
1469
|
+
const last = items[items.length - 1];
|
|
1470
|
+
const nextCursor = rows.length > page.limit && last !== undefined
|
|
1471
|
+
? encodeCursor({ createdAt: last.occurredAt, id: last.id })
|
|
1472
|
+
: null;
|
|
1473
|
+
return { items, nextCursor };
|
|
1474
|
+
}, this.scopeFor());
|
|
1475
|
+
}
|
|
1476
|
+
scopeFor() {
|
|
1477
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Starting a reindex, and reporting on one.
|
|
1482
|
+
*
|
|
1483
|
+
* The work is the worker's. This writes the intent — `layers.reindex_state` —
|
|
1484
|
+
* counts what there is to do, and declares the new named vector on the
|
|
1485
|
+
* collection.
|
|
1486
|
+
*
|
|
1487
|
+
* That last part belongs here and an earlier version of this comment said the
|
|
1488
|
+
* opposite: that Qdrant creates a named vector when a point is first written
|
|
1489
|
+
* with it, so the worker's first batch would bring it into existence. It does
|
|
1490
|
+
* not. `updateVectors` against an undeclared name answers
|
|
1491
|
+
* `Not existing vector name error`, and the reindex that assumed otherwise
|
|
1492
|
+
* started cleanly, reported `running`, and failed every document forever with
|
|
1493
|
+
* the progress gauge sitting at zero — which is step 1 of the sequence in
|
|
1494
|
+
* docs/architecture.md being skipped, and the document has it first for a
|
|
1495
|
+
* reason.
|
|
1496
|
+
*
|
|
1497
|
+
* Doing it here rather than in the worker also puts the failure where somebody
|
|
1498
|
+
* is looking: an operator who cannot create the vector finds out from the
|
|
1499
|
+
* response to their own request, not by watching a counter not move.
|
|
1500
|
+
*/
|
|
1501
|
+
export class PostgresReindex {
|
|
1502
|
+
pool;
|
|
1503
|
+
vectors;
|
|
1504
|
+
role;
|
|
1505
|
+
principalsCache;
|
|
1506
|
+
constructor(pool, vectors, role,
|
|
1507
|
+
/** See `principalsFor`. Absent means recompute the closure every time. */
|
|
1508
|
+
principalsCache) {
|
|
1509
|
+
this.pool = pool;
|
|
1510
|
+
this.vectors = vectors;
|
|
1511
|
+
this.role = role;
|
|
1512
|
+
this.principalsCache = principalsCache;
|
|
1513
|
+
}
|
|
1514
|
+
get scope() {
|
|
1515
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1516
|
+
}
|
|
1517
|
+
async start(auth, layerId, providerId) {
|
|
1518
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1519
|
+
// Administering a layer, not reading it. A reindex rewrites every
|
|
1520
|
+
// vector in the layer and changes which model answers every future
|
|
1521
|
+
// query — `read` is nowhere near enough, and `write` is about putting
|
|
1522
|
+
// documents in rather than about the layer itself.
|
|
1523
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'admin');
|
|
1524
|
+
if (plan.kind === 'none')
|
|
1525
|
+
return undefined;
|
|
1526
|
+
// Locked for the whole decision. Two starts arriving together would
|
|
1527
|
+
// both read "no reindex running" and both write a state, and the loser
|
|
1528
|
+
// would leave a shadow vector nothing is filling.
|
|
1529
|
+
const { rows: layers } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
|
|
1530
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL
|
|
1531
|
+
FOR UPDATE`, [auth.orgId, layerId]);
|
|
1532
|
+
const layer = layers[0];
|
|
1533
|
+
if (layer === undefined)
|
|
1534
|
+
return undefined;
|
|
1535
|
+
if (plan.kind === 'scoped' && !plan.layers.includes(layer.id))
|
|
1536
|
+
return undefined;
|
|
1537
|
+
const running = fromStateJson(layer.reindex_state);
|
|
1538
|
+
if (running?.status === 'running') {
|
|
1539
|
+
// The live remaining count, not zero. A caller who collides with a
|
|
1540
|
+
// running reindex is asking "how far has it got" as much as "why was
|
|
1541
|
+
// I refused", and the answer they get should be the same one `GET`
|
|
1542
|
+
// gives — not a placeholder that reads as finished.
|
|
1543
|
+
const remaining = await this.remaining(client, auth.orgId, layerId, running.shadowVector);
|
|
1544
|
+
return {
|
|
1545
|
+
kind: 'conflict',
|
|
1546
|
+
status: this.statusOf(layerId, layer.vector_name, running, remaining),
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
// The provider may be the organization's own or the installation-wide
|
|
1550
|
+
// default, which is the one row with a NULL org_id. Same rule the
|
|
1551
|
+
// policy on that table encodes; repeated here because a caller naming
|
|
1552
|
+
// another tenant's provider must get "no such provider" rather than a
|
|
1553
|
+
// reindex onto a model they cannot reach.
|
|
1554
|
+
const { rows: providers } = await client.query(`SELECT model, dimensions FROM embedding_providers
|
|
1555
|
+
WHERE id = $1 AND (org_id = $2 OR org_id IS NULL)`, [providerId, auth.orgId]);
|
|
1556
|
+
const provider = providers[0];
|
|
1557
|
+
if (provider === undefined)
|
|
1558
|
+
return { kind: 'unknown_provider' };
|
|
1559
|
+
const shadow = vectorName(provider.model, provider.dimensions);
|
|
1560
|
+
if (shadow === layer.vector_name) {
|
|
1561
|
+
return { kind: 'already_current', vectorName: shadow };
|
|
1562
|
+
}
|
|
1563
|
+
// Counted now, and it is a starting figure rather than a bound.
|
|
1564
|
+
// Documents ingested during the reindex have no shadow vector either,
|
|
1565
|
+
// so the same pass picks them up and `done` can pass `total` — which is
|
|
1566
|
+
// why the progress ratio is clamped rather than trusted.
|
|
1567
|
+
// Which half this layer starts in.
|
|
1568
|
+
//
|
|
1569
|
+
// A named vector cannot be added to a live Qdrant collection, so the
|
|
1570
|
+
// slot has to already exist. If the organization's collection has it —
|
|
1571
|
+
// because an earlier reindex made it — this layer goes straight to
|
|
1572
|
+
// embedding. If not, the collection has to be rebuilt with room for it
|
|
1573
|
+
// first, and that is org-wide work the worker does.
|
|
1574
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1', [auth.orgId]);
|
|
1575
|
+
const collection = orgs[0]?.vector_collection;
|
|
1576
|
+
if (collection === undefined)
|
|
1577
|
+
return undefined;
|
|
1578
|
+
const present = await this.vectors.vectorsOf(collection);
|
|
1579
|
+
const width = present[shadow];
|
|
1580
|
+
if (width !== undefined && width !== provider.dimensions) {
|
|
1581
|
+
// Same name, different width. The name is derived from model and
|
|
1582
|
+
// dimensions so this cannot come from the API — it means the vector
|
|
1583
|
+
// was made by hand, and writing 768 values into a 1024-wide slot is
|
|
1584
|
+
// the kind of failure that surfaces months later as bad recall.
|
|
1585
|
+
return { kind: 'unknown_provider' };
|
|
1586
|
+
}
|
|
1587
|
+
const phase = width === undefined ? 'copying' : 'embedding';
|
|
1588
|
+
// One copy at a time for an organization. Two layers starting reindexes
|
|
1589
|
+
// onto two different models would otherwise each rebuild the
|
|
1590
|
+
// collection, and the second would throw away the first's work along
|
|
1591
|
+
// with the pointer that made it live.
|
|
1592
|
+
if (phase === 'copying') {
|
|
1593
|
+
const { rows: busy } = await client.query(`SELECT count(*)::text AS n FROM layers
|
|
1594
|
+
WHERE org_id = $1 AND deleted_at IS NULL
|
|
1595
|
+
AND reindex_state ->> 'status' = 'running'
|
|
1596
|
+
AND reindex_state ->> 'phase' = 'copying'`, [auth.orgId]);
|
|
1597
|
+
if (Number(busy[0]?.n ?? 0) > 0) {
|
|
1598
|
+
const other = fromStateJson(layer.reindex_state) ?? {
|
|
1599
|
+
status: 'running',
|
|
1600
|
+
phase: 'copying',
|
|
1601
|
+
shadowVector: shadow,
|
|
1602
|
+
providerId,
|
|
1603
|
+
startedAt: new Date().toISOString(),
|
|
1604
|
+
total: 0,
|
|
1605
|
+
done: 0,
|
|
1606
|
+
failed: 0,
|
|
1607
|
+
};
|
|
1608
|
+
return { kind: 'conflict', status: this.statusOf(layerId, layer.vector_name, other, 0) };
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const total = await this.remaining(client, auth.orgId, layerId, shadow);
|
|
1612
|
+
const state = {
|
|
1613
|
+
status: 'running',
|
|
1614
|
+
phase,
|
|
1615
|
+
shadowVector: shadow,
|
|
1616
|
+
providerId,
|
|
1617
|
+
startedAt: new Date().toISOString(),
|
|
1618
|
+
total,
|
|
1619
|
+
done: 0,
|
|
1620
|
+
failed: 0,
|
|
1621
|
+
};
|
|
1622
|
+
await client.query('UPDATE layers SET reindex_state = $3::jsonb WHERE org_id = $1 AND id = $2', [auth.orgId, layerId, JSON.stringify(toStateJson(state))]);
|
|
1623
|
+
// `remaining = total`: nothing has been reindexed yet, so `done` is 0.
|
|
1624
|
+
// Passing 0 here said `done: total, progress: 1` on the 202 — a reindex
|
|
1625
|
+
// reporting itself finished in the same breath as starting.
|
|
1626
|
+
return {
|
|
1627
|
+
kind: 'started',
|
|
1628
|
+
status: this.statusOf(layerId, layer.vector_name, state, total),
|
|
1629
|
+
};
|
|
1630
|
+
}, this.scope);
|
|
1631
|
+
}
|
|
1632
|
+
async status(auth, layerId) {
|
|
1633
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1634
|
+
// `read` here, not `admin`. Knowing that the layer you can search is
|
|
1635
|
+
// being migrated is not an administrative fact — it explains why a
|
|
1636
|
+
// result set moved — and hiding it from someone who can already read
|
|
1637
|
+
// the layer buys nothing.
|
|
1638
|
+
const plan = resolve(await contextFor(client, auth, this.principalsCache), 'read');
|
|
1639
|
+
if (plan.kind === 'none')
|
|
1640
|
+
return undefined;
|
|
1641
|
+
const { rows } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
|
|
1642
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, layerId]);
|
|
1643
|
+
const layer = rows[0];
|
|
1644
|
+
if (layer === undefined)
|
|
1645
|
+
return undefined;
|
|
1646
|
+
if (plan.kind === 'scoped' && !plan.layers.includes(layer.id))
|
|
1647
|
+
return undefined;
|
|
1648
|
+
const state = fromStateJson(layer.reindex_state);
|
|
1649
|
+
if (state === undefined)
|
|
1650
|
+
return undefined;
|
|
1651
|
+
// The live remaining count rather than a stored `done`, because the
|
|
1652
|
+
// worker never writes one — the number is derived, so there is nothing
|
|
1653
|
+
// to go stale between batches.
|
|
1654
|
+
const left = await this.remaining(client, auth.orgId, layerId, state.shadowVector);
|
|
1655
|
+
return this.statusOf(layerId, layer.vector_name, state, left);
|
|
1656
|
+
}, this.scope);
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Live documents in the layer that do not yet carry this vector.
|
|
1660
|
+
*
|
|
1661
|
+
* The same predicate the worker's claim uses and the same one the switch is
|
|
1662
|
+
* guarded by. Three places, one meaning — and if they ever disagree, the
|
|
1663
|
+
* switch is the one that is right, because it is the only one inside the
|
|
1664
|
+
* statement that performs it.
|
|
1665
|
+
*/
|
|
1666
|
+
async remaining(client, orgId, layerId, shadowVector) {
|
|
1667
|
+
const { rows } = await client.query(`SELECT count(*)::text AS n FROM documents
|
|
1668
|
+
WHERE org_id = $1 AND layer_id = $2 AND deleted_at IS NULL AND chunk_count > 0
|
|
1669
|
+
AND reindexed_vector IS DISTINCT FROM $3`, [orgId, layerId, shadowVector]);
|
|
1670
|
+
return Number(rows[0]?.n ?? 0);
|
|
1671
|
+
}
|
|
1672
|
+
statusOf(layerId, currentVector, state, remaining) {
|
|
1673
|
+
const done = state.status === 'complete' ? state.total : Math.max(0, state.total - remaining);
|
|
1674
|
+
const live = { ...state, done };
|
|
1675
|
+
return {
|
|
1676
|
+
layerId,
|
|
1677
|
+
status: state.status,
|
|
1678
|
+
phase: state.phase,
|
|
1679
|
+
shadowVector: state.shadowVector,
|
|
1680
|
+
currentVector,
|
|
1681
|
+
providerId: state.providerId,
|
|
1682
|
+
startedAt: state.startedAt,
|
|
1683
|
+
finishedAt: state.finishedAt ?? null,
|
|
1684
|
+
total: state.total,
|
|
1685
|
+
done,
|
|
1686
|
+
failed: state.failed,
|
|
1687
|
+
progress: reindexProgress(live),
|
|
1688
|
+
error: state.error ?? null,
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
672
1692
|
//# sourceMappingURL=adapters.js.map
|