@nacre.work/api 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapters.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aclTags, buildFilter, effectivePrincipals, fromStateJson, loadGrants, loadScopeTree, PostgresGroupGraph, referenceAllows, reindexProgress, resolve, toStateJson, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
1
+ import { buildFilter, cachedEffectivePrincipals, documentKey, effectivePrincipals, fromStateJson, loadGrants, loadGroupsVersion, loadScopeTree, PostgresGroupGraph, referenceAllows, reindexProgress, activeResolver, admitIngest, toStateJson, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { encodeCursor, pageOf } from './pagination.js';
4
4
  import { applyRanking } from './rerank.js';
@@ -6,6 +6,8 @@ export class PostgresDocuments {
6
6
  pool;
7
7
  payload;
8
8
  role;
9
+ principalsCache;
10
+ presign;
9
11
  constructor(pool,
10
12
  /**
11
13
  * What writes metadata into the payload of a document's points.
@@ -14,10 +16,22 @@ export class PostgresDocuments {
14
16
  * has no business searching, and a port that could would let a later change
15
17
  * reach the index without a plan.
16
18
  */
17
- payload, role) {
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) {
18
30
  this.pool = pool;
19
31
  this.payload = payload;
20
32
  this.role = role;
33
+ this.principalsCache = principalsCache;
34
+ this.presign = presign;
21
35
  }
22
36
  /**
23
37
  * Undefined for absent, for another organization's, and for one this caller
@@ -39,11 +53,11 @@ export class PostgresDocuments {
39
53
  // The plan first. Reading the row and then checking is the same
40
54
  // information leak with extra steps if the check is ever forgotten,
41
55
  // and the layer a document sits in is what the grant is on.
42
- const plan = resolve(await contextFor(client, auth), 'read');
56
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
43
57
  if (plan.kind === 'none')
44
58
  return undefined;
45
59
  const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
46
- d.updated_at, d.metadata
60
+ d.updated_at, d.metadata, d.source_type, d.source_ref
47
61
  FROM documents d
48
62
  JOIN layers l ON l.id = d.layer_id AND l.org_id = d.org_id
49
63
  WHERE d.org_id = $1 AND d.id = $2 AND d.deleted_at IS NULL`, [orgId, documentId]);
@@ -71,6 +85,17 @@ export class PostgresDocuments {
71
85
  // tag has no way to tell a successful write from a dropped one — and
72
86
  // this field was dropped by the handler for as long as it existed.
73
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
+ : {}),
74
99
  };
75
100
  }, this.role === undefined ? {} : { role: this.role });
76
101
  }
@@ -102,7 +127,7 @@ export class PostgresDocuments {
102
127
  const collection = orgs[0]?.vector_collection;
103
128
  if (collection === undefined)
104
129
  return false;
105
- const plan = resolve(await contextFor(client, auth), 'write');
130
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'write');
106
131
  if (plan.kind === 'none')
107
132
  return false;
108
133
  const { rows } = await client.query(`SELECT layer_id FROM documents
@@ -146,11 +171,10 @@ export class NacreSearchService {
146
171
  const collection = orgs[0]?.vector_collection;
147
172
  if (collection === undefined)
148
173
  return undefined;
149
- const graph = await PostgresGroupGraph.load(client, auth.orgId);
150
- const principals = effectivePrincipals(auth.principal, graph);
174
+ const principals = await principalsFor(client, auth, this.deps.principalsCache);
151
175
  const grants = await loadGrants(client, auth.orgId, principals);
152
176
  const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
153
- const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
177
+ const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
154
178
  // Every live layer with the model it is on. Small — an installation
155
179
  // runs tens of layers per organization, not thousands — and it is what
156
180
  // decides how many branches the query has.
@@ -495,6 +519,10 @@ export class PostgresAudit {
495
519
  event.requestId,
496
520
  ]);
497
521
  }, this.role === undefined ? {} : { role: this.role });
522
+ // Forwarding to a module's sinks is deliberately *not* here. It is
523
+ // `withAuditSinks`, applied to whatever port a surface was handed, because
524
+ // a sink wants every recorded event and not every event this adapter
525
+ // happened to record.
498
526
  }
499
527
  }
500
528
  /**
@@ -569,7 +597,6 @@ export class HttpEmbedder {
569
597
  });
570
598
  }
571
599
  }
572
- export { aclTags };
573
600
  export class NacreIngest {
574
601
  deps;
575
602
  constructor(deps) {
@@ -583,11 +610,10 @@ export class NacreIngest {
583
610
  }
584
611
  /** The layers this caller may write to, by slug. */
585
612
  async writableLayer(client, auth, layerSlug) {
586
- const graph = await PostgresGroupGraph.load(client, auth.orgId);
587
- const principals = effectivePrincipals(auth.principal, graph);
613
+ const principals = await principalsFor(client, auth, this.deps.principalsCache);
588
614
  const grants = await loadGrants(client, auth.orgId, principals);
589
615
  const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
590
- const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
616
+ const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
591
617
  if (plan.kind === 'none')
592
618
  return undefined;
593
619
  const { rows } = await client.query(`SELECT id FROM layers WHERE org_id = $1 AND slug = $2 AND deleted_at IS NULL`, [auth.orgId, layerSlug]);
@@ -605,9 +631,46 @@ export class NacreIngest {
605
631
  const layerId = await this.writableLayer(client, auth, request.layer);
606
632
  if (layerId === undefined)
607
633
  return undefined;
608
- const source = request.content !== undefined ? request.content : request.url;
609
- const sourceType = request.content !== undefined ? 'inline' : 'url';
634
+ const inline = request.content !== undefined;
635
+ const source = inline ? request.content : request.url;
610
636
  const hash = createHash('sha256').update(source, 'utf8').digest('hex');
637
+ // A module's ingest gate, after write is established and before anything
638
+ // is stored, so a refusal leaves neither a row nor an object behind. No
639
+ // gate registered is the open core admitting every document a caller may
640
+ // write, which is what it did before this point existed. A refusal
641
+ // becomes the handler's 4xx; it is not `undefined`, because that would
642
+ // read as "unwritable" and hide the layer the caller just wrote against.
643
+ const refusal = await admitIngest({
644
+ orgId: auth.orgId,
645
+ layerId,
646
+ principal: auth.principal,
647
+ role: auth.role,
648
+ externalId: request.externalId,
649
+ bytes: new TextEncoder().encode(source).byteLength,
650
+ });
651
+ if (refusal !== undefined) {
652
+ return { refused: true, status: refusal.status, reason: refusal.reason };
653
+ }
654
+ // Bytes to object storage before the row, never after.
655
+ //
656
+ // The reverse fails unrecoverably, which is the same argument the
657
+ // delete path is ordered by: a row that names an object which was never
658
+ // written is a document the worker fails on every attempt, forever,
659
+ // with the API having answered `queued`. A PUT with no row is a stray
660
+ // object at a deterministic key that the next ingest overwrites.
661
+ //
662
+ // Only for inline content. A `url` document is a reference already, and
663
+ // copying somebody else's URL into our bucket at ingest time would be
664
+ // a fetch on the request path — which is exactly what the worker does
665
+ // later, with a timeout and a sandbox.
666
+ let sourceType = inline ? 'inline' : 'url';
667
+ let sourceRef = source;
668
+ if (inline && this.deps.objects !== undefined) {
669
+ const key = documentKey(auth.orgId, layerId, request.externalId);
670
+ await this.deps.objects.put(key, new TextEncoder().encode(source), 'text/plain');
671
+ sourceType = 's3';
672
+ sourceRef = key;
673
+ }
611
674
  const { rows } = await client.query(`INSERT INTO documents
612
675
  (org_id, layer_id, external_id, source_type, source_ref, title, content_hash, metadata, status)
613
676
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending')
@@ -640,7 +703,7 @@ export class NacreIngest {
640
703
  layerId,
641
704
  request.externalId,
642
705
  sourceType,
643
- source,
706
+ sourceRef,
644
707
  request.title ?? null,
645
708
  `sha256:${hash}`,
646
709
  JSON.stringify(request.metadata),
@@ -667,11 +730,10 @@ export class NacreIngest {
667
730
  const collection = orgs[0]?.vector_collection;
668
731
  if (collection === undefined)
669
732
  return false;
670
- const graph = await PostgresGroupGraph.load(client, auth.orgId);
671
- const principals = effectivePrincipals(auth.principal, graph);
733
+ const principals = await principalsFor(client, auth, this.deps.principalsCache);
672
734
  const grants = await loadGrants(client, auth.orgId, principals);
673
735
  const tree = await loadScopeTree(client, auth.orgId, [documentId]);
674
- const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
736
+ const plan = activeResolver().resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'write');
675
737
  if (plan.kind === 'none')
676
738
  return false;
677
739
  const { rows } = await client.query(`SELECT layer_id FROM documents
@@ -727,9 +789,13 @@ export class NacreIngest {
727
789
  export class PostgresJobs {
728
790
  pool;
729
791
  role;
730
- constructor(pool, role) {
792
+ principalsCache;
793
+ constructor(pool, role,
794
+ /** See `principalsFor`. Absent means recompute the closure every time. */
795
+ principalsCache) {
731
796
  this.pool = pool;
732
797
  this.role = role;
798
+ this.principalsCache = principalsCache;
733
799
  }
734
800
  async read(auth, jobId) {
735
801
  const orgId = auth.orgId;
@@ -740,7 +806,7 @@ export class PostgresJobs {
740
806
  // different projection — and needs the same check. The status and the
741
807
  // error string are both facts about a document the caller may not be
742
808
  // allowed to know exists.
743
- const plan = resolve(await contextFor(client, auth), 'read');
809
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
744
810
  if (plan.kind === 'none')
745
811
  return undefined;
746
812
  const { rows } = await client.query(`SELECT id, status, error, chunk_count, layer_id FROM documents
@@ -773,10 +839,24 @@ export class PostgresJobs {
773
839
  }, this.role === undefined ? {} : { role: this.role });
774
840
  }
775
841
  }
842
+ async function principalsFor(client, auth, cache) {
843
+ if (cache === undefined) {
844
+ return effectivePrincipals(auth.principal, await PostgresGroupGraph.load(client, auth.orgId));
845
+ }
846
+ // In the same transaction as the grants that follow, so the key and the grant
847
+ // set describe one instant. Read separately, there is a window where the
848
+ // version says "fresh" about a set loaded before a change.
849
+ const groupsVersion = await loadGroupsVersion(client, auth.orgId);
850
+ return cachedEffectivePrincipals({
851
+ orgId: auth.orgId,
852
+ principal: auth.principal,
853
+ groupsVersion,
854
+ ttlSeconds: cache.ttlSeconds,
855
+ }, cache.store, () => PostgresGroupGraph.load(client, auth.orgId));
856
+ }
776
857
  /** The per-request permission context, loaded once and asked several questions. */
777
- async function contextFor(client, auth) {
778
- const graph = await PostgresGroupGraph.load(client, auth.orgId);
779
- const principals = effectivePrincipals(auth.principal, graph);
858
+ async function contextFor(client, auth, cache) {
859
+ const principals = await principalsFor(client, auth, cache);
780
860
  const grants = await loadGrants(client, auth.orgId, principals);
781
861
  const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
782
862
  return { orgId: auth.orgId, role: auth.role, principals, grants, tree };
@@ -785,6 +865,7 @@ export class PostgresLayers {
785
865
  pool;
786
866
  vectors;
787
867
  role;
868
+ principalsCache;
788
869
  constructor(pool,
789
870
  /**
790
871
  * Only to ask which named vectors the organization's collection has.
@@ -797,10 +878,13 @@ export class PostgresLayers {
797
878
  * answered 202 and the row says `pending`. Checking here is what turns that
798
879
  * into a collection rebuild instead.
799
880
  */
800
- vectors, role) {
881
+ vectors, role,
882
+ /** See `principalsFor`. Absent means recompute the closure every time. */
883
+ principalsCache) {
801
884
  this.pool = pool;
802
885
  this.vectors = vectors;
803
886
  this.role = role;
887
+ this.principalsCache = principalsCache;
804
888
  }
805
889
  get scope() {
806
890
  return this.role === undefined ? {} : { role: this.role };
@@ -815,7 +899,7 @@ export class PostgresLayers {
815
899
  */
816
900
  async list(auth, page) {
817
901
  return withOrg(this.pool, auth.orgId, async (client) => {
818
- const plan = resolve(await contextFor(client, auth), 'read');
902
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
819
903
  if (plan.kind === 'none')
820
904
  return { items: [], nextCursor: null };
821
905
  // The count comes from the same statement. It is what a catalog is
@@ -829,7 +913,12 @@ export class PostgresLayers {
829
913
  // grants exist — a commercial module — this becomes a number that
830
914
  // discloses more than the caller can reach, and it has to be recomputed
831
915
  // per caller or dropped.
916
+ // `created_at::text` beside `created_at`, and the cursor is built from
917
+ // it. See `Position.createdAt` — a timestamp that has been through a
918
+ // JavaScript `Date` is truncated to milliseconds, and a truncated bound
919
+ // matches the row it came from all over again.
832
920
  const projection = `l.id, l.slug, l.name, l.workspace_id, l.description, l.created_at,
921
+ l.created_at::text AS created_at_text,
833
922
  (SELECT count(*) FROM documents d
834
923
  WHERE d.layer_id = l.id AND d.deleted_at IS NULL) AS document_count`;
835
924
  // Ordered by (created_at, id) rather than by slug, because that is the
@@ -857,17 +946,20 @@ export class PostgresLayers {
857
946
  documentCount: Number(r.document_count),
858
947
  createdAt: r.created_at.toISOString(),
859
948
  }));
860
- return pageOf(layers, page, (l) => ({ createdAt: l.createdAt, id: l.id }));
949
+ return pageOf(layers, page, (l, i) => ({
950
+ createdAt: rows[i].created_at_text,
951
+ id: l.id,
952
+ }));
861
953
  }, this.scope);
862
954
  }
863
955
  async update(auth, layerId, input) {
864
- return updateLayer(this.pool, auth, layerId, input, this.role);
956
+ return updateLayer(this.pool, auth, layerId, input, this.role, this.principalsCache);
865
957
  }
866
958
  async create(auth, input) {
867
959
  if (!/^[0-9a-f-]{36}$/i.test(input.workspaceId))
868
960
  return { kind: 'denied' };
869
961
  return withOrg(this.pool, auth.orgId, async (client) => {
870
- const context = await contextFor(client, auth);
962
+ const context = await contextFor(client, auth, this.principalsCache);
871
963
  // `referenceAllows` and not `resolve`: the question is "may they
872
964
  // administer this one workspace", which is what the reference answers
873
965
  // directly. Reading it out of a flattened plan would mean inferring a
@@ -994,9 +1086,13 @@ export class PostgresLayers {
994
1086
  export class PostgresWorkspaces {
995
1087
  pool;
996
1088
  role;
997
- constructor(pool, role) {
1089
+ principalsCache;
1090
+ constructor(pool, role,
1091
+ /** See `principalsFor`. Absent means recompute the closure every time. */
1092
+ principalsCache) {
998
1093
  this.pool = pool;
999
1094
  this.role = role;
1095
+ this.principalsCache = principalsCache;
1000
1096
  }
1001
1097
  get scope() {
1002
1098
  return this.role === undefined ? {} : { role: this.role };
@@ -1022,7 +1118,7 @@ export class PostgresWorkspaces {
1022
1118
  */
1023
1119
  async list(auth, page) {
1024
1120
  return withOrg(this.pool, auth.orgId, async (client) => {
1025
- const context = await contextFor(client, auth);
1121
+ const context = await contextFor(client, auth, this.principalsCache);
1026
1122
  // No early return on `plan.kind === 'none'`, and that is the whole
1027
1123
  // point of this endpoint.
1028
1124
  //
@@ -1033,13 +1129,28 @@ export class PostgresWorkspaces {
1033
1129
  // administer, and therefore unable to create the first layer in it,
1034
1130
  // which is the deadlock this endpoint was added to break. It was
1035
1131
  // written that way first and caught by running it.
1036
- const plan = resolve(context, 'read');
1037
- const { rows } = await client.query(`SELECT w.id, w.slug, w.name, w.created_at,
1132
+ const plan = activeResolver().resolve(context, 'read');
1133
+ // Seek and limit in SQL.
1134
+ //
1135
+ // Neither was here: the statement fetched every workspace in the
1136
+ // organization, ignored `page.after` entirely, and handed the whole
1137
+ // list to `pageOf` — which then answered "there is another page"
1138
+ // because the list was at least as long as the limit. So
1139
+ // `GET /v1/workspaces` returned the same complete list on every page
1140
+ // and never terminated, and a client following `next_cursor` looped
1141
+ // forever. Found by a test that walked the listing one item at a time.
1142
+ const after = page?.after;
1143
+ const seek = after === undefined ? '' : ' AND (w.created_at, w.id) > ($2::timestamptz, $3::uuid)';
1144
+ // One extra, because the filter below removes rows the caller may not
1145
+ // reach: without it a page whose last row is filtered out would report
1146
+ // itself as the end of the collection.
1147
+ const cap = page === undefined ? '' : ` LIMIT ${page.limit + 1}`;
1148
+ const { rows } = await client.query(`SELECT w.id, w.slug, w.name, w.created_at, w.created_at::text AS created_at_text,
1038
1149
  (SELECT array_agg(l.id) FROM layers l
1039
1150
  WHERE l.workspace_id = w.id AND l.deleted_at IS NULL) AS layer_ids
1040
1151
  FROM workspaces w
1041
- WHERE w.org_id = $1 AND w.deleted_at IS NULL
1042
- ORDER BY w.created_at, w.id`, [auth.orgId]);
1152
+ WHERE w.org_id = $1 AND w.deleted_at IS NULL${seek}
1153
+ ORDER BY w.created_at, w.id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
1043
1154
  const reachable = rows.filter((w) => {
1044
1155
  if (plan.kind === 'all')
1045
1156
  return true;
@@ -1052,14 +1163,28 @@ export class PostgresWorkspaces {
1052
1163
  // for a workspace with nothing in it yet.
1053
1164
  return referenceAllows(context, { type: 'workspace', id: w.id }, 'read');
1054
1165
  });
1055
- const items = reachable.map((w) => ({
1166
+ const fetched = page === undefined ? rows : rows.slice(0, page.limit);
1167
+ const items = reachable
1168
+ .filter((w) => fetched.some((f) => f.id === w.id))
1169
+ .map((w) => ({
1056
1170
  id: w.id,
1057
1171
  slug: w.slug,
1058
1172
  name: w.name,
1059
1173
  layerCount: (w.layer_ids ?? []).length,
1060
1174
  createdAt: w.created_at.toISOString(),
1061
1175
  }));
1062
- return pageOf(items, page, (w) => ({ createdAt: w.createdAt, id: w.id }));
1176
+ // From the last row **fetched**, not the last returned — the same rule
1177
+ // the grant listing follows and for the same reason: the filter above
1178
+ // removes rows the caller may not reach, and a cursor taken from a
1179
+ // survivor would skip everything the filter dropped after it.
1180
+ //
1181
+ // Which makes a short page normal here rather than a signal, and
1182
+ // `next_cursor` the only thing that says whether more exist.
1183
+ const lastFetched = fetched[fetched.length - 1];
1184
+ const nextCursor = page !== undefined && rows.length > page.limit && lastFetched !== undefined
1185
+ ? encodeCursor({ createdAt: lastFetched.created_at_text, id: lastFetched.id })
1186
+ : null;
1187
+ return { items, nextCursor };
1063
1188
  }, this.scope);
1064
1189
  }
1065
1190
  /**
@@ -1114,13 +1239,13 @@ export class PostgresWorkspaces {
1114
1239
  * description is built from, so an agent's picture of what a layer holds
1115
1240
  * changes with it. That is the reason it is editable at all.
1116
1241
  */
1117
- async function updateLayer(pool, auth, layerId, input, role) {
1242
+ async function updateLayer(pool, auth, layerId, input, role, principalsCache) {
1118
1243
  if (!/^[0-9a-f-]{36}$/i.test(layerId))
1119
1244
  return false;
1120
1245
  if (input.name === undefined && input.description === undefined)
1121
1246
  return false;
1122
1247
  return withOrg(pool, auth.orgId, async (client) => {
1123
- const context = await contextFor(client, auth);
1248
+ const context = await contextFor(client, auth, principalsCache);
1124
1249
  // The layer's workspace, before anything is decided — the grant is on the
1125
1250
  // workspace, so there is nothing to ask until we know which one.
1126
1251
  const { rows } = await client.query(`SELECT workspace_id FROM layers
@@ -1147,9 +1272,13 @@ async function updateLayer(pool, auth, layerId, input, role) {
1147
1272
  export class PostgresGrants {
1148
1273
  pool;
1149
1274
  role;
1150
- constructor(pool, role) {
1275
+ principalsCache;
1276
+ constructor(pool, role,
1277
+ /** See `principalsFor`. Absent means recompute the closure every time. */
1278
+ principalsCache) {
1151
1279
  this.pool = pool;
1152
1280
  this.role = role;
1281
+ this.principalsCache = principalsCache;
1153
1282
  }
1154
1283
  get scope() {
1155
1284
  return this.role === undefined ? {} : { role: this.role };
@@ -1177,15 +1306,15 @@ export class PostgresGrants {
1177
1306
  */
1178
1307
  async list(auth, page) {
1179
1308
  return withOrg(this.pool, auth.orgId, async (client) => {
1180
- const context = await contextFor(client, auth);
1181
- const plan = resolve(context, 'admin');
1309
+ const context = await contextFor(client, auth, this.principalsCache);
1310
+ const plan = activeResolver().resolve(context, 'admin');
1182
1311
  if (plan.kind === 'none')
1183
1312
  return { items: [], nextCursor: null };
1184
1313
  const after = page?.after;
1185
1314
  const seek = after === undefined ? '' : ' AND (created_at, id) > ($2::timestamptz, $3::uuid)';
1186
1315
  const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
1187
1316
  const { rows } = await client.query(`SELECT id, principal_type, principal_id, scope_type, scope_id, permission, effect, source,
1188
- created_at
1317
+ created_at, created_at::text AS created_at_text
1189
1318
  FROM grants WHERE org_id = $1${seek} ORDER BY created_at, id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
1190
1319
  const visible = rows
1191
1320
  .filter((r) => referenceAllows(context, { type: r.scope_type, id: r.scope_id }, 'admin'))
@@ -1205,7 +1334,7 @@ export class PostgresGrants {
1205
1334
  // it — a page of grants silently missing from an administrator's view.
1206
1335
  const lastFetched = rows[rows.length - 1];
1207
1336
  const nextCursor = page !== undefined && rows.length >= page.limit && lastFetched !== undefined
1208
- ? encodeCursor({ createdAt: lastFetched.created_at.toISOString(), id: lastFetched.id })
1337
+ ? encodeCursor({ createdAt: lastFetched.created_at_text, id: lastFetched.id })
1209
1338
  : null;
1210
1339
  return { items: visible, nextCursor };
1211
1340
  }, this.scope);
@@ -1215,12 +1344,39 @@ export class PostgresGrants {
1215
1344
  return undefined;
1216
1345
  }
1217
1346
  return withOrg(this.pool, auth.orgId, async (client) => {
1218
- const context = await contextFor(client, auth);
1347
+ const context = await contextFor(client, auth, this.principalsCache);
1219
1348
  // Admin on the scope being granted, not admin in general. Otherwise
1220
1349
  // anyone holding admin on one layer could grant themselves another.
1221
1350
  if (!referenceAllows(context, { type: input.scopeType, id: input.scopeId }, 'admin')) {
1222
1351
  return undefined;
1223
1352
  }
1353
+ // And the scope has to exist in this organization, which the check
1354
+ // above does not establish. `referenceAllows` returns `true` for
1355
+ // `org_admin` on its third line — rule 3, admin on every scope
1356
+ // implicitly — and it returns *before* the scope is placed, so an
1357
+ // administrator naming any uuid at all passed.
1358
+ //
1359
+ // Not a leak, and the reason is worth stating rather than assumed: a
1360
+ // grant on a scope that is not here becomes `should: layer_id = X`
1361
+ // beside an unconditional `must: org_id = <this tenant>`, and points
1362
+ // carrying layer X belong to another tenant and live in another
1363
+ // collection. The second line of defence holds. What got written was a
1364
+ // row that permits nothing and points at nothing.
1365
+ //
1366
+ // It still has to be refused. An administrator reading `/v1/grants`
1367
+ // sees a grant they cannot explain on an id they cannot look up, and
1368
+ // `404` stops meaning what invariant 4 says it means — "no permission"
1369
+ // and "no such object" are one answer, which requires that naming a
1370
+ // nonexistent object *reaches* that answer rather than succeeding.
1371
+ //
1372
+ // Written out per table rather than assembled, so the table name can
1373
+ // never come from a caller-supplied string. `scopeType` is a checked
1374
+ // union already; this makes it structural.
1375
+ const exists = await client.query(input.scopeType === 'workspace'
1376
+ ? `SELECT 1 FROM workspaces WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`
1377
+ : `SELECT 1 FROM layers WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, input.scopeId]);
1378
+ if (exists.rowCount !== 1)
1379
+ return undefined;
1224
1380
  const { rows } = await client.query(`INSERT INTO grants
1225
1381
  (org_id, principal_type, principal_id, scope_type, scope_id, permission, effect, source)
1226
1382
  VALUES ($1,$2,$3,$4,$5,$6,'allow','api')
@@ -1274,7 +1430,7 @@ export class PostgresGrants {
1274
1430
  // because the object is a grant rather than a document.
1275
1431
  if (row === undefined)
1276
1432
  return false;
1277
- const context = await contextFor(client, auth);
1433
+ const context = await contextFor(client, auth, this.principalsCache);
1278
1434
  const scope = {
1279
1435
  type: row.scope_type,
1280
1436
  id: row.scope_id,
@@ -1318,9 +1474,13 @@ export class PostgresGrants {
1318
1474
  export class PostgresAuditReader {
1319
1475
  pool;
1320
1476
  role;
1321
- constructor(pool, role) {
1477
+ principalsCache;
1478
+ constructor(pool, role,
1479
+ /** See `principalsFor`. Absent means recompute the closure every time. */
1480
+ principalsCache) {
1322
1481
  this.pool = pool;
1323
1482
  this.role = role;
1483
+ this.principalsCache = principalsCache;
1324
1484
  }
1325
1485
  /**
1326
1486
  * Actions that record a substantive access to a document's contents.
@@ -1370,7 +1530,8 @@ export class PostgresAuditReader {
1370
1530
  // without a second query and without a count — a count over a
1371
1531
  // retention window is the expensive thing this endpoint must not do on
1372
1532
  // every request.
1373
- const { rows } = await client.query(`SELECT id::text, occurred_at, actor_type, actor_id, actor_label, action,
1533
+ const { rows } = await client.query(`SELECT id::text, occurred_at, occurred_at::text AS occurred_at_text,
1534
+ actor_type, actor_id, actor_label, action,
1374
1535
  surface, client, target, result, detail, request_id
1375
1536
  FROM audit_events
1376
1537
  WHERE ${where.join(' AND ')}
@@ -1390,9 +1551,19 @@ export class PostgresAuditReader {
1390
1551
  detail: r.detail ?? {},
1391
1552
  requestId: r.request_id,
1392
1553
  }));
1554
+ // From the row rather than from the mapped record, because the record
1555
+ // carries an ISO string and this cursor needs full precision.
1556
+ //
1557
+ // This one **skips** rather than repeats, and that is the same bug seen
1558
+ // from the other side: the ordering is descending, so a bound truncated
1559
+ // downwards excludes the row it came from *and* everything between the
1560
+ // truncated value and the real one. On a listing that would lose a page
1561
+ // of layers; on the access log it silently loses events, in the one
1562
+ // place the product promises a precise answer.
1563
+ const lastRow = rows[Math.min(page.limit, rows.length) - 1];
1393
1564
  const last = items[items.length - 1];
1394
- const nextCursor = rows.length > page.limit && last !== undefined
1395
- ? encodeCursor({ createdAt: last.occurredAt, id: last.id })
1565
+ const nextCursor = rows.length > page.limit && last !== undefined && lastRow !== undefined
1566
+ ? encodeCursor({ createdAt: lastRow.occurred_at_text, id: last.id })
1396
1567
  : null;
1397
1568
  return { items, nextCursor };
1398
1569
  }, this.scopeFor());
@@ -1426,10 +1597,14 @@ export class PostgresReindex {
1426
1597
  pool;
1427
1598
  vectors;
1428
1599
  role;
1429
- constructor(pool, vectors, role) {
1600
+ principalsCache;
1601
+ constructor(pool, vectors, role,
1602
+ /** See `principalsFor`. Absent means recompute the closure every time. */
1603
+ principalsCache) {
1430
1604
  this.pool = pool;
1431
1605
  this.vectors = vectors;
1432
1606
  this.role = role;
1607
+ this.principalsCache = principalsCache;
1433
1608
  }
1434
1609
  get scope() {
1435
1610
  return this.role === undefined ? {} : { role: this.role };
@@ -1440,7 +1615,7 @@ export class PostgresReindex {
1440
1615
  // vector in the layer and changes which model answers every future
1441
1616
  // query — `read` is nowhere near enough, and `write` is about putting
1442
1617
  // documents in rather than about the layer itself.
1443
- const plan = resolve(await contextFor(client, auth), 'admin');
1618
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'admin');
1444
1619
  if (plan.kind === 'none')
1445
1620
  return undefined;
1446
1621
  // Locked for the whole decision. Two starts arriving together would
@@ -1555,7 +1730,7 @@ export class PostgresReindex {
1555
1730
  // being migrated is not an administrative fact — it explains why a
1556
1731
  // result set moved — and hiding it from someone who can already read
1557
1732
  // the layer buys nothing.
1558
- const plan = resolve(await contextFor(client, auth), 'read');
1733
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'read');
1559
1734
  if (plan.kind === 'none')
1560
1735
  return undefined;
1561
1736
  const { rows } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
@@ -1606,7 +1781,87 @@ export class PostgresReindex {
1606
1781
  failed: state.failed,
1607
1782
  progress: reindexProgress(live),
1608
1783
  error: state.error ?? null,
1784
+ check: state.check ?? null,
1609
1785
  };
1610
1786
  }
1611
1787
  }
1788
+ /**
1789
+ * The reference query set behind the reindex recall gate.
1790
+ *
1791
+ * `admin` on the layer for both operations, and that is a decision rather than
1792
+ * a copy of the reindex adapter above. Reading the set reveals which documents
1793
+ * an operator considers the canonical answers to a query, which is a statement
1794
+ * about the layer's contents; rule 7 makes `admin` the permission that implies
1795
+ * being allowed to see them, and rule 6 means `write` does not.
1796
+ */
1797
+ export class PostgresReferenceQueries {
1798
+ pool;
1799
+ role;
1800
+ principalsCache;
1801
+ constructor(pool, role, principalsCache) {
1802
+ this.pool = pool;
1803
+ this.role = role;
1804
+ this.principalsCache = principalsCache;
1805
+ }
1806
+ get scope() {
1807
+ return this.role === undefined ? {} : { role: this.role };
1808
+ }
1809
+ /**
1810
+ * The layer, if this caller may administer it.
1811
+ *
1812
+ * `undefined` for a layer that is not there and for one they may not touch —
1813
+ * one answer, which the handler turns into `404`. Deleted layers are not
1814
+ * there: a reference set on a deleted layer describes nothing.
1815
+ */
1816
+ async administrable(client, auth, layerId) {
1817
+ const plan = activeResolver().resolve(await contextFor(client, auth, this.principalsCache), 'admin');
1818
+ if (plan.kind === 'none')
1819
+ return false;
1820
+ if (plan.kind === 'scoped' && !plan.layers.includes(layerId))
1821
+ return false;
1822
+ const { rows } = await client.query('SELECT id FROM layers WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL', [auth.orgId, layerId]);
1823
+ return rows.length > 0;
1824
+ }
1825
+ async list(auth, layerId) {
1826
+ return withOrg(this.pool, auth.orgId, async (client) => {
1827
+ if (!(await this.administrable(client, auth, layerId)))
1828
+ return undefined;
1829
+ const { rows } = await client.query(`SELECT id, query, expected FROM reference_queries
1830
+ WHERE org_id = $1 AND layer_id = $2 ORDER BY ordinal`, [auth.orgId, layerId]);
1831
+ return rows.map((r) => ({ id: r.id, query: r.query, expected: r.expected }));
1832
+ }, this.scope);
1833
+ }
1834
+ async replace(auth, layerId, queries) {
1835
+ return withOrg(this.pool, auth.orgId, async (client) => {
1836
+ if (!(await this.administrable(client, auth, layerId)))
1837
+ return undefined;
1838
+ // Delete then insert, in the transaction `withOrg` already opened. A
1839
+ // set is one statement about the layer, so there is no moment at which
1840
+ // half of the old one and half of the new one are both in the table —
1841
+ // which matters because the worker reads this to decide whether a layer
1842
+ // has a gate at all.
1843
+ await client.query('DELETE FROM reference_queries WHERE org_id = $1 AND layer_id = $2', [
1844
+ auth.orgId,
1845
+ layerId,
1846
+ ]);
1847
+ const inserted = [];
1848
+ for (const [ordinal, q] of queries.entries()) {
1849
+ const { rows } = await client.query(`INSERT INTO reference_queries (org_id, layer_id, query, expected, ordinal)
1850
+ VALUES ($1, $2, $3, $4, $5) RETURNING id`, [auth.orgId, layerId, q.query, [...q.expected], ordinal]);
1851
+ inserted.push({
1852
+ id: rows[0]?.id,
1853
+ query: q.query,
1854
+ expected: [...q.expected],
1855
+ });
1856
+ }
1857
+ // The external ids are deliberately **not** validated against
1858
+ // `documents` here. A reference set is often written before the
1859
+ // documents it names are ingested, and refusing it then would make the
1860
+ // gate impossible to set up on a new layer. The check resolves them
1861
+ // when it runs, and an entry that resolves to nothing fails the reindex
1862
+ // by name — which is the moment it actually matters.
1863
+ return inserted;
1864
+ }, this.scope);
1865
+ }
1866
+ }
1612
1867
  //# sourceMappingURL=adapters.js.map