@nacre.work/core 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +3 -0
  2. package/dist/authz/cache.d.ts +54 -0
  3. package/dist/authz/cache.d.ts.map +1 -0
  4. package/dist/authz/cache.js +63 -0
  5. package/dist/authz/cache.js.map +1 -0
  6. package/dist/authz/filter.d.ts +60 -0
  7. package/dist/authz/filter.d.ts.map +1 -0
  8. package/dist/authz/filter.js +54 -0
  9. package/dist/authz/filter.js.map +1 -0
  10. package/dist/authz/index.d.ts +15 -0
  11. package/dist/authz/index.d.ts.map +1 -0
  12. package/dist/authz/index.js +9 -0
  13. package/dist/authz/index.js.map +1 -0
  14. package/dist/authz/permissions.d.ts +20 -0
  15. package/dist/authz/permissions.d.ts.map +1 -0
  16. package/dist/authz/permissions.js +43 -0
  17. package/dist/authz/permissions.js.map +1 -0
  18. package/dist/authz/principals.d.ts +34 -0
  19. package/dist/authz/principals.d.ts.map +1 -0
  20. package/dist/authz/principals.js +42 -0
  21. package/dist/authz/principals.js.map +1 -0
  22. package/dist/authz/reference.d.ts +25 -0
  23. package/dist/authz/reference.d.ts.map +1 -0
  24. package/dist/authz/reference.js +34 -0
  25. package/dist/authz/reference.js.map +1 -0
  26. package/dist/authz/resolve.d.ts +45 -0
  27. package/dist/authz/resolve.d.ts.map +1 -0
  28. package/dist/authz/resolve.js +98 -0
  29. package/dist/authz/resolve.js.map +1 -0
  30. package/dist/authz/scope-tree.d.ts +52 -0
  31. package/dist/authz/scope-tree.d.ts.map +1 -0
  32. package/dist/authz/scope-tree.js +56 -0
  33. package/dist/authz/scope-tree.js.map +1 -0
  34. package/dist/authz/store.d.ts +50 -0
  35. package/dist/authz/store.d.ts.map +1 -0
  36. package/dist/authz/store.js +115 -0
  37. package/dist/authz/store.js.map +1 -0
  38. package/dist/authz/tags.d.ts +40 -0
  39. package/dist/authz/tags.d.ts.map +1 -0
  40. package/dist/authz/tags.js +48 -0
  41. package/dist/authz/tags.js.map +1 -0
  42. package/dist/config.d.ts +73 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +157 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/db/client.d.ts +38 -0
  47. package/dist/db/client.d.ts.map +1 -0
  48. package/dist/db/client.js +34 -0
  49. package/dist/db/client.js.map +1 -0
  50. package/dist/db/migrate.d.ts +32 -0
  51. package/dist/db/migrate.d.ts.map +1 -0
  52. package/dist/db/migrate.js +103 -0
  53. package/dist/db/migrate.js.map +1 -0
  54. package/dist/index.d.ts +16 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/metrics.d.ts +77 -0
  59. package/dist/metrics.d.ts.map +1 -0
  60. package/dist/metrics.js +179 -0
  61. package/dist/metrics.js.map +1 -0
  62. package/dist/migrate-main.d.ts +2 -0
  63. package/dist/migrate-main.d.ts.map +1 -0
  64. package/dist/migrate-main.js +34 -0
  65. package/dist/migrate-main.js.map +1 -0
  66. package/dist/migrations/0001_init.sql +226 -0
  67. package/dist/migrations/0002_rls_coverage.sql +128 -0
  68. package/dist/migrations/0003_groups_version.sql +48 -0
  69. package/dist/migrations/0004_acl_version.sql +26 -0
  70. package/dist/migrations/0005_grants_bump_version.sql +38 -0
  71. package/dist/migrations/0006_layer_slug_unique.sql +47 -0
  72. package/dist/migrations/0007_index_lease.sql +42 -0
  73. package/dist/observability.d.ts +11 -0
  74. package/dist/observability.d.ts.map +1 -0
  75. package/dist/observability.js +55 -0
  76. package/dist/observability.js.map +1 -0
  77. package/dist/tsconfig.tsbuildinfo +1 -0
  78. package/dist/types.d.ts +54 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +15 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/vector/query.d.ts +140 -0
  83. package/dist/vector/query.d.ts.map +1 -0
  84. package/dist/vector/query.js +94 -0
  85. package/dist/vector/query.js.map +1 -0
  86. package/dist/vector/search.d.ts +62 -0
  87. package/dist/vector/search.d.ts.map +1 -0
  88. package/dist/vector/search.js +98 -0
  89. package/dist/vector/search.js.map +1 -0
  90. package/package.json +39 -0
@@ -0,0 +1,98 @@
1
+ import { principalRef } from '../types.js';
2
+ import { satisfies } from './permissions.js';
3
+ /**
4
+ * Turn grants into the set of things the caller may reach.
5
+ *
6
+ * Where this differs from `reference.ts`: the reference answers "may they touch
7
+ * this one scope?", which is the question the rules are written in. A search
8
+ * needs the inverse — the whole permitted set, up front, so it can go into the
9
+ * query as a pre-filter. Computing it by asking the reference per document
10
+ * would be a post-filter with extra steps.
11
+ *
12
+ * The property-based test exists because these two are different algorithms
13
+ * answering the same question, and only one of them is obviously correct.
14
+ */
15
+ export function resolve(input, permission) {
16
+ const { orgId, role, principals, grants, tree } = input;
17
+ // Rule 2, before anything else: platform_admin reads no documents.
18
+ if (role === 'platform_admin')
19
+ return { kind: 'none' };
20
+ // Rule 3, with rule 7 making admin cover read and write.
21
+ if (role === 'org_admin')
22
+ return { kind: 'all' };
23
+ // Rule 1 is a precondition. A grant from another tenant is discarded, not
24
+ // weighed. Rules 6 and 7 are entirely inside `satisfies`.
25
+ const applicable = grants.filter((g) => g.orgId === orgId &&
26
+ principals.has(principalRef(g.principal)) &&
27
+ satisfies(g.permission, permission));
28
+ const denied = { workspaces: new Set(), layers: new Set(), docs: new Set() };
29
+ const allowed = { workspaces: new Set(), layers: new Set(), docs: new Set() };
30
+ for (const g of applicable) {
31
+ const side = g.effect === 'deny' ? denied : allowed;
32
+ switch (g.scope.type) {
33
+ case 'workspace':
34
+ side.workspaces.add(g.scope.id);
35
+ break;
36
+ case 'layer':
37
+ side.layers.add(g.scope.id);
38
+ break;
39
+ case 'document':
40
+ side.docs.add(g.scope.id);
41
+ break;
42
+ }
43
+ }
44
+ // Rule 4 downward: a denied workspace denies each of its layers.
45
+ const deniedLayers = new Set(denied.layers);
46
+ for (const workspace of denied.workspaces) {
47
+ for (const layer of tree.layersOf(workspace))
48
+ deniedLayers.add(layer);
49
+ }
50
+ // Rule 5: subtract, never compare depth. A layer allowed explicitly under a
51
+ // denied workspace stays denied — "most specific wins" would allow it, and
52
+ // that is a different model from the one specified.
53
+ const layers = new Set();
54
+ for (const workspace of allowed.workspaces) {
55
+ for (const layer of tree.layersOf(workspace)) {
56
+ if (!deniedLayers.has(layer))
57
+ layers.add(layer);
58
+ }
59
+ }
60
+ for (const layer of allowed.layers) {
61
+ if (!deniedLayers.has(layer))
62
+ layers.add(layer);
63
+ }
64
+ // Document-scoped allows. The pseudocode in the specification subtracts only
65
+ // document-scoped denies here, which loses the truth-table row where a deny
66
+ // on the workspace beats an allow on a document beneath it. Ancestors are
67
+ // checked instead — see the note in docs/authz.md.
68
+ const extraDocs = new Set();
69
+ for (const doc of allowed.docs) {
70
+ if (denied.docs.has(doc))
71
+ continue;
72
+ const layer = tree.layerOf(doc);
73
+ // An unplaceable document cannot be checked against its ancestors, so it
74
+ // is denied. Rule I3: a permission that cannot be evaluated is refused.
75
+ if (layer === undefined)
76
+ continue;
77
+ if (deniedLayers.has(layer))
78
+ continue;
79
+ const workspace = tree.workspaceOf(layer);
80
+ if (workspace !== undefined && denied.workspaces.has(workspace))
81
+ continue;
82
+ if (layers.has(layer))
83
+ continue; // already covered by the layer grant
84
+ extraDocs.add(doc);
85
+ }
86
+ if (layers.size === 0 && extraDocs.size === 0)
87
+ return { kind: 'none' };
88
+ return {
89
+ kind: 'scoped',
90
+ layers: [...layers].sort(),
91
+ extraDocs: [...extraDocs].sort(),
92
+ // Kept even when the document sits outside every allowed layer: it costs
93
+ // one clause and it is the difference between "excluded" and "excluded
94
+ // unless someone later widens the layer set".
95
+ deniedDocs: [...denied.docs].sort(),
96
+ };
97
+ }
98
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../authz/resolve.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ import type { Scope } from '../types.js';
2
+ /**
3
+ * A read-only view of the scope tree for one organization: workspace → layer →
4
+ * document.
5
+ *
6
+ * The resolver needs this to apply rules 4 and 5. Inheritance runs downward, so
7
+ * a grant on a workspace reaches its layers; deny beats allow at any depth, so
8
+ * an explicit allow on a document has to be checked against denies on its
9
+ * ancestors. Neither is decidable from the grants alone.
10
+ *
11
+ * Only documents named in a grant ever need placing, so `layerOf` is called a
12
+ * handful of times per query, not once per document in the index. Nothing here
13
+ * enumerates documents.
14
+ */
15
+ export interface ScopeTree {
16
+ /** Layers directly under a workspace. Empty for an unknown workspace. */
17
+ layersOf(workspaceId: string): readonly string[];
18
+ /** The workspace a layer belongs to, or undefined if the layer is unknown. */
19
+ workspaceOf(layerId: string): string | undefined;
20
+ /** The layer a document belongs to, or undefined if the document is unknown. */
21
+ layerOf(documentId: string): string | undefined;
22
+ }
23
+ export interface ScopeTreeData {
24
+ /** workspace id → layer ids */
25
+ readonly layers: Readonly<Record<string, readonly string[]>>;
26
+ /** document id → layer id */
27
+ readonly documents: Readonly<Record<string, string>>;
28
+ }
29
+ /**
30
+ * In-memory tree, used by tests and by anything holding a small catalog.
31
+ *
32
+ * An unknown id resolves to undefined rather than throwing. That is not
33
+ * laziness: a grant can outlive the layer it referenced, and the resolver's
34
+ * behaviour for an unplaceable scope has to be *deny*, which reads more clearly
35
+ * as "we could not place it, so it grants nothing" than as an exception the
36
+ * caller might catch and continue past.
37
+ */
38
+ export declare class MemoryScopeTree implements ScopeTree {
39
+ #private;
40
+ constructor(data: ScopeTreeData);
41
+ layersOf(workspaceId: string): readonly string[];
42
+ workspaceOf(layerId: string): string | undefined;
43
+ layerOf(documentId: string): string | undefined;
44
+ }
45
+ /**
46
+ * The chain from a scope up to the organization root, the scope itself first.
47
+ *
48
+ * Used to answer "is any ancestor of this denied?", which is what makes deny
49
+ * absolute rather than nearest-wins.
50
+ */
51
+ export declare function ancestry(scope: Scope, tree: ScopeTree): readonly Scope[];
52
+ //# sourceMappingURL=scope-tree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-tree.d.ts","sourceRoot":"","sources":["../../authz/scope-tree.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAExC;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,SAAS;IACxB,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAA;IAChD,8EAA8E;IAC9E,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAChD,gFAAgF;IAChF,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CAChD;AAED,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAC5D,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED;;;;;;;;GAQG;AACH,qBAAa,eAAgB,YAAW,SAAS;;gBAKnC,IAAI,EAAE,aAAa;IAQ/B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE;IAIhD,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIhD,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,EAAE,CAgBxE"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * In-memory tree, used by tests and by anything holding a small catalog.
3
+ *
4
+ * An unknown id resolves to undefined rather than throwing. That is not
5
+ * laziness: a grant can outlive the layer it referenced, and the resolver's
6
+ * behaviour for an unplaceable scope has to be *deny*, which reads more clearly
7
+ * as "we could not place it, so it grants nothing" than as an exception the
8
+ * caller might catch and continue past.
9
+ */
10
+ export class MemoryScopeTree {
11
+ #layersByWorkspace;
12
+ #workspaceByLayer = new Map();
13
+ #layerByDocument;
14
+ constructor(data) {
15
+ this.#layersByWorkspace = new Map(Object.entries(data.layers));
16
+ for (const [workspace, layers] of this.#layersByWorkspace) {
17
+ for (const layer of layers)
18
+ this.#workspaceByLayer.set(layer, workspace);
19
+ }
20
+ this.#layerByDocument = new Map(Object.entries(data.documents));
21
+ }
22
+ layersOf(workspaceId) {
23
+ return this.#layersByWorkspace.get(workspaceId) ?? [];
24
+ }
25
+ workspaceOf(layerId) {
26
+ return this.#workspaceByLayer.get(layerId);
27
+ }
28
+ layerOf(documentId) {
29
+ return this.#layerByDocument.get(documentId);
30
+ }
31
+ }
32
+ /**
33
+ * The chain from a scope up to the organization root, the scope itself first.
34
+ *
35
+ * Used to answer "is any ancestor of this denied?", which is what makes deny
36
+ * absolute rather than nearest-wins.
37
+ */
38
+ export function ancestry(scope, tree) {
39
+ switch (scope.type) {
40
+ case 'workspace':
41
+ return [scope];
42
+ case 'layer': {
43
+ const workspace = tree.workspaceOf(scope.id);
44
+ return workspace === undefined
45
+ ? [scope]
46
+ : [scope, { type: 'workspace', id: workspace }];
47
+ }
48
+ case 'document': {
49
+ const layer = tree.layerOf(scope.id);
50
+ if (layer === undefined)
51
+ return [scope];
52
+ return [scope, ...ancestry({ type: 'layer', id: layer }, tree)];
53
+ }
54
+ }
55
+ }
56
+ //# sourceMappingURL=scope-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-tree.js","sourceRoot":"","sources":["../../authz/scope-tree.ts"],"names":[],"mappings":"AA+BA;;;;;;;;GAQG;AACH,MAAM,OAAO,eAAe;IACjB,kBAAkB,CAAgC;IAClD,iBAAiB,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC7C,gBAAgB,CAAqB;IAE9C,YAAY,IAAmB;QAC7B,IAAI,CAAC,kBAAkB,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAA;QAC9D,KAAK,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC1D,KAAK,MAAM,KAAK,IAAI,MAAM;gBAAE,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAA;QAC1E,CAAC;QACD,IAAI,CAAC,gBAAgB,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAA;IACjE,CAAC;IAED,QAAQ,CAAC,WAAmB;QAC1B,OAAO,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,CAAA;IACvD,CAAC;IAED,WAAW,CAAC,OAAe;QACzB,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IAC5C,CAAC;IAED,OAAO,CAAC,UAAkB;QACxB,OAAO,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;IAC9C,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,KAAY,EAAE,IAAe;IACpD,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,WAAW;YACd,OAAO,CAAC,KAAK,CAAC,CAAA;QAChB,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YAC5C,OAAO,SAAS,KAAK,SAAS;gBAC5B,CAAC,CAAC,CAAC,KAAK,CAAC;gBACT,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAA;QACnD,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YACpC,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,CAAC,KAAK,CAAC,CAAA;YACvC,OAAO,CAAC,KAAK,EAAE,GAAG,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC,CAAA;QACjE,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,50 @@
1
+ import type { PoolClient } from 'pg';
2
+ import type { Grant, PrincipalRef } from '../types.js';
3
+ import type { GroupGraph } from './principals.js';
4
+ import { type ScopeTree } from './scope-tree.js';
5
+ /**
6
+ * Load the grants that could bear on a request.
7
+ *
8
+ * Deliberately *not* filtered by permission in SQL. `satisfies` decides which
9
+ * grants matter, and duplicating rules 6 and 7 into a `WHERE` clause would mean
10
+ * two places to keep in step — with the SQL one being the copy nobody tests
11
+ * against the truth table. The row count here is bounded by the caller's
12
+ * principals, not by the size of the index.
13
+ *
14
+ * The connection must already be scoped with `withOrg`, so RLS is a second
15
+ * bound on top of the explicit `org_id` below.
16
+ */
17
+ export declare function loadGrants(client: PoolClient, orgId: string, principals: ReadonlySet<PrincipalRef>): Promise<readonly Grant[]>;
18
+ /**
19
+ * Build the scope tree the resolver needs.
20
+ *
21
+ * Only the documents named in the grants are placed — the tree never
22
+ * enumerates the index. Deleted layers and documents are excluded, so a grant
23
+ * pointing at one becomes unplaceable and is therefore refused, which is the
24
+ * behaviour rule I3 asks for.
25
+ */
26
+ export declare function loadScopeTree(client: PoolClient, orgId: string, documentIds: readonly string[]): Promise<ScopeTree>;
27
+ /**
28
+ * Group membership, read straight from the join table.
29
+ *
30
+ * Nesting is resolved by `effectivePrincipals`, not here, and not in SQL. A
31
+ * recursive CTE would be faster and would also have to answer what to do about
32
+ * a cycle — `WITH RECURSIVE` needs a cycle clause or it does not terminate, and
33
+ * getting that wrong is a hung connection rather than a wrong answer. Keeping
34
+ * the traversal in one tested place is worth a second round trip.
35
+ */
36
+ export declare class PostgresGroupGraph implements GroupGraph {
37
+ #private;
38
+ private constructor();
39
+ static load(client: PoolClient, orgId: string): Promise<PostgresGroupGraph>;
40
+ directGroups(principal: PrincipalRef): readonly string[];
41
+ }
42
+ /**
43
+ * The organization's current groups_version.
44
+ *
45
+ * Read in the same transaction as the grants, so the cache key and the grant
46
+ * set describe the same instant. Reading it separately would leave a window
47
+ * where the version says "fresh" about a set loaded before a change.
48
+ */
49
+ export declare function loadGroupsVersion(client: PoolClient, orgId: string): Promise<number>;
50
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../authz/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,IAAI,CAAA;AAEpC,OAAO,KAAK,EAAE,KAAK,EAAyB,YAAY,EAAE,MAAM,aAAa,CAAA;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACjD,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEjE;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,GACpC,OAAO,CAAC,SAAS,KAAK,EAAE,CAAC,CAsC3B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,SAAS,MAAM,EAAE,GAC7B,OAAO,CAAC,SAAS,CAAC,CAsBpB;AAED;;;;;;;;GAQG;AACH,qBAAa,kBAAmB,YAAW,UAAU;;IAGnD,OAAO;WAIM,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqBjF,YAAY,CAAC,SAAS,EAAE,YAAY,GAAG,SAAS,MAAM,EAAE;CAGzD;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY1F"}
@@ -0,0 +1,115 @@
1
+ import { MemoryScopeTree } from './scope-tree.js';
2
+ /**
3
+ * Load the grants that could bear on a request.
4
+ *
5
+ * Deliberately *not* filtered by permission in SQL. `satisfies` decides which
6
+ * grants matter, and duplicating rules 6 and 7 into a `WHERE` clause would mean
7
+ * two places to keep in step — with the SQL one being the copy nobody tests
8
+ * against the truth table. The row count here is bounded by the caller's
9
+ * principals, not by the size of the index.
10
+ *
11
+ * The connection must already be scoped with `withOrg`, so RLS is a second
12
+ * bound on top of the explicit `org_id` below.
13
+ */
14
+ export async function loadGrants(client, orgId, principals) {
15
+ const refs = [...principals];
16
+ if (refs.length === 0)
17
+ return [];
18
+ // Split the `{type}:{id}` refs into parallel arrays so the query stays a
19
+ // single indexed lookup rather than a string comparison per row.
20
+ const types = [];
21
+ const ids = [];
22
+ for (const ref of refs) {
23
+ const sep = ref.indexOf(':');
24
+ types.push(ref.slice(0, sep));
25
+ ids.push(ref.slice(sep + 1));
26
+ }
27
+ const { rows } = await client.query(`SELECT principal_type, principal_id, scope_type, scope_id, permission, effect
28
+ FROM grants
29
+ WHERE org_id = $1
30
+ AND (principal_type, principal_id) IN (
31
+ SELECT * FROM unnest($2::text[], $3::uuid[])
32
+ )`, [orgId, types, ids]);
33
+ return rows.map((r) => ({
34
+ orgId,
35
+ principal: { type: r.principal_type, id: r.principal_id },
36
+ scope: { type: r.scope_type, id: r.scope_id },
37
+ permission: r.permission,
38
+ effect: r.effect,
39
+ }));
40
+ }
41
+ /**
42
+ * Build the scope tree the resolver needs.
43
+ *
44
+ * Only the documents named in the grants are placed — the tree never
45
+ * enumerates the index. Deleted layers and documents are excluded, so a grant
46
+ * pointing at one becomes unplaceable and is therefore refused, which is the
47
+ * behaviour rule I3 asks for.
48
+ */
49
+ export async function loadScopeTree(client, orgId, documentIds) {
50
+ const { rows: layerRows } = await client.query(`SELECT id, workspace_id FROM layers WHERE org_id = $1 AND deleted_at IS NULL`, [orgId]);
51
+ const layers = {};
52
+ for (const row of layerRows) {
53
+ ;
54
+ (layers[row.workspace_id] ??= []).push(row.id);
55
+ }
56
+ const documents = {};
57
+ if (documentIds.length > 0) {
58
+ const { rows } = await client.query(`SELECT id, layer_id FROM documents
59
+ WHERE org_id = $1 AND deleted_at IS NULL AND id = ANY($2::uuid[])`, [orgId, [...documentIds]]);
60
+ for (const row of rows)
61
+ documents[row.id] = row.layer_id;
62
+ }
63
+ return new MemoryScopeTree({ layers, documents });
64
+ }
65
+ /**
66
+ * Group membership, read straight from the join table.
67
+ *
68
+ * Nesting is resolved by `effectivePrincipals`, not here, and not in SQL. A
69
+ * recursive CTE would be faster and would also have to answer what to do about
70
+ * a cycle — `WITH RECURSIVE` needs a cycle clause or it does not terminate, and
71
+ * getting that wrong is a hung connection rather than a wrong answer. Keeping
72
+ * the traversal in one tested place is worth a second round trip.
73
+ */
74
+ export class PostgresGroupGraph {
75
+ #edges;
76
+ constructor(edges) {
77
+ this.#edges = edges;
78
+ }
79
+ static async load(client, orgId) {
80
+ const { rows } = await client.query(`SELECT group_id, member_user, member_group FROM group_members WHERE org_id = $1`, [orgId]);
81
+ const edges = new Map();
82
+ for (const row of rows) {
83
+ const member = row.member_user
84
+ ? `user:${row.member_user}`
85
+ : row.member_group
86
+ ? `group:${row.member_group}`
87
+ : null;
88
+ if (member === null)
89
+ continue;
90
+ (edges.get(member) ?? edges.set(member, []).get(member)).push(row.group_id);
91
+ }
92
+ return new PostgresGroupGraph(edges);
93
+ }
94
+ directGroups(principal) {
95
+ return this.#edges.get(principal) ?? [];
96
+ }
97
+ }
98
+ /**
99
+ * The organization's current groups_version.
100
+ *
101
+ * Read in the same transaction as the grants, so the cache key and the grant
102
+ * set describe the same instant. Reading it separately would leave a window
103
+ * where the version says "fresh" about a set loaded before a change.
104
+ */
105
+ export async function loadGroupsVersion(client, orgId) {
106
+ const { rows } = await client.query('SELECT groups_version FROM organizations WHERE id = $1', [orgId]);
107
+ const value = rows[0]?.groups_version;
108
+ if (value === undefined) {
109
+ // No organization means no permissions to evaluate. Rule I3: deny rather
110
+ // than default, and a version of 0 is a key nothing else will collide with.
111
+ return 0;
112
+ }
113
+ return Number(value);
114
+ }
115
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../authz/store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import type { PrincipalRef } from '../types.js';
2
+ /**
3
+ * ACL tags: the denormalization described in docs/authz.md 3.6.
4
+ *
5
+ * Each indexed chunk carries hashes of the principals allowed to read it, so a
6
+ * filter can narrow on the payload instead of joining back to Postgres. The
7
+ * thing to keep in view is what it is *not*:
8
+ *
9
+ * **`acl_tags` is a cache. The `grants` table is the source of truth.** A grant
10
+ * change starts a background recomputation, and until it finishes the tags are
11
+ * stale. That is why the layer bound stays in every query rather than being
12
+ * "simplified" away once tags exist — see the collision note below.
13
+ */
14
+ /** Bytes kept from each hash. The default matches `NACRE_ACL_TAG_HASH_BYTES`. */
15
+ export declare const DEFAULT_TAG_BYTES = 8;
16
+ /**
17
+ * Hash one principal reference into a tag.
18
+ *
19
+ * Truncation is deliberate and it does collide: 8 bytes over a large enough
20
+ * population will eventually produce two principals with the same tag, and a
21
+ * collision means a false tag match — a chunk appearing to be readable by
22
+ * someone it was not granted to.
23
+ *
24
+ * That is only safe because **the query is also bounded by the allowed
25
+ * `layer_id` list**. A colliding tag inside a layer the caller may already read
26
+ * changes nothing; a colliding tag in a layer they may not read never gets
27
+ * considered, because the layer bound excludes it first. Remove the layer bound
28
+ * and the truncation becomes a leak — which is the single most important thing
29
+ * to know before touching this file.
30
+ */
31
+ export declare function principalTag(ref: PrincipalRef, bytes?: number): string;
32
+ /**
33
+ * The tags for a set of principals, sorted so the payload is stable.
34
+ *
35
+ * Stability matters for the recomputation job: an unstable order makes every
36
+ * chunk look changed on every pass, and the propagation metric stops meaning
37
+ * anything.
38
+ */
39
+ export declare function aclTags(principals: Iterable<PrincipalRef>, bytes?: number): readonly string[];
40
+ //# sourceMappingURL=tags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../../authz/tags.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C;;;;;;;;;;;GAWG;AAEH,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAElC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,GAAE,MAA0B,GAAG,MAAM,CAMzF;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CACrB,UAAU,EAAE,QAAQ,CAAC,YAAY,CAAC,EAClC,KAAK,GAAE,MAA0B,GAChC,SAAS,MAAM,EAAE,CAEnB"}
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * ACL tags: the denormalization described in docs/authz.md 3.6.
4
+ *
5
+ * Each indexed chunk carries hashes of the principals allowed to read it, so a
6
+ * filter can narrow on the payload instead of joining back to Postgres. The
7
+ * thing to keep in view is what it is *not*:
8
+ *
9
+ * **`acl_tags` is a cache. The `grants` table is the source of truth.** A grant
10
+ * change starts a background recomputation, and until it finishes the tags are
11
+ * stale. That is why the layer bound stays in every query rather than being
12
+ * "simplified" away once tags exist — see the collision note below.
13
+ */
14
+ /** Bytes kept from each hash. The default matches `NACRE_ACL_TAG_HASH_BYTES`. */
15
+ export const DEFAULT_TAG_BYTES = 8;
16
+ /**
17
+ * Hash one principal reference into a tag.
18
+ *
19
+ * Truncation is deliberate and it does collide: 8 bytes over a large enough
20
+ * population will eventually produce two principals with the same tag, and a
21
+ * collision means a false tag match — a chunk appearing to be readable by
22
+ * someone it was not granted to.
23
+ *
24
+ * That is only safe because **the query is also bounded by the allowed
25
+ * `layer_id` list**. A colliding tag inside a layer the caller may already read
26
+ * changes nothing; a colliding tag in a layer they may not read never gets
27
+ * considered, because the layer bound excludes it first. Remove the layer bound
28
+ * and the truncation becomes a leak — which is the single most important thing
29
+ * to know before touching this file.
30
+ */
31
+ export function principalTag(ref, bytes = DEFAULT_TAG_BYTES) {
32
+ if (bytes < 1 || bytes > 32) {
33
+ throw new Error(`tag width must be between 1 and 32 bytes, got ${bytes}`);
34
+ }
35
+ const digest = createHash('sha256').update(ref, 'utf8').digest();
36
+ return `h:${digest.subarray(0, bytes).toString('hex')}`;
37
+ }
38
+ /**
39
+ * The tags for a set of principals, sorted so the payload is stable.
40
+ *
41
+ * Stability matters for the recomputation job: an unstable order makes every
42
+ * chunk look changed on every pass, and the propagation metric stops meaning
43
+ * anything.
44
+ */
45
+ export function aclTags(principals, bytes = DEFAULT_TAG_BYTES) {
46
+ return [...new Set([...principals].map((p) => principalTag(p, bytes)))].sort();
47
+ }
48
+ //# sourceMappingURL=tags.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tags.js","sourceRoot":"","sources":["../../authz/tags.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAIxC;;;;;;;;;;;GAWG;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAA;AAElC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,GAAiB,EAAE,QAAgB,iBAAiB;IAC/E,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,iDAAiD,KAAK,EAAE,CAAC,CAAA;IAC3E,CAAC;IACD,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAA;IAChE,OAAO,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AACzD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,OAAO,CACrB,UAAkC,EAClC,QAAgB,iBAAiB;IAEjC,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AAChF,CAAC"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Configuration, validated whole at startup.
3
+ *
4
+ * Two rules from docs/config.md, and both are about the same failure:
5
+ *
6
+ * **Validate everything at boot and exit if anything is missing or
7
+ * contradictory.** Not on first use. A service that starts and then fails the
8
+ * first request that needs the missing value looks healthy to an orchestrator,
9
+ * gets traffic, and reports the problem as an error rate.
10
+ *
11
+ * **No silent defaults for secrets or URLs.** A default that quietly points at
12
+ * localhost is how a production deployment ends up talking to nothing and
13
+ * reporting success. Defaults are fine for tunables — a cache TTL has an
14
+ * obviously right value — and never fine for anything naming a host or
15
+ * carrying a credential.
16
+ */
17
+ export interface Config {
18
+ readonly env: 'development' | 'production';
19
+ readonly canonicalUrl: string;
20
+ readonly logLevel: string;
21
+ readonly logFormat: 'json' | 'text';
22
+ readonly pgUrl: string;
23
+ readonly pgPoolMax: number;
24
+ readonly qdrantUrl: string;
25
+ readonly qdrantApiKey: string | undefined;
26
+ readonly vectorTenancy: 'collection' | 'shared';
27
+ readonly redisUrl: string;
28
+ readonly embeddingEndpoint: string;
29
+ readonly embeddingModel: string;
30
+ readonly embeddingDim: number;
31
+ readonly parserEndpoint: string;
32
+ readonly rerankerEndpoint: string | undefined;
33
+ readonly rerankerEnabled: boolean;
34
+ readonly jwtIssuer: string;
35
+ readonly jwtAudience: string;
36
+ readonly accessTokenTtl: number;
37
+ readonly aclCacheTtl: number;
38
+ readonly aclPropagationSla: number;
39
+ readonly aclTagHashBytes: number;
40
+ /**
41
+ * How long a tombstoned document keeps its vectors before the sweep removes
42
+ * them. A tunable, so a default is fine — and it is not a correctness knob:
43
+ * invariant I5 is held by `deleted = false` in every query, not by this.
44
+ */
45
+ readonly gcGrace: number;
46
+ /**
47
+ * How long a claimed document may stay claimed before another worker may take
48
+ * it back. Must exceed the slowest legitimate indexing run, because a lease
49
+ * that expires while the work is still going produces two workers on one
50
+ * document — wasteful rather than wrong, since ingest is idempotent, but it
51
+ * is the wrong direction to be wrong in.
52
+ */
53
+ readonly indexLease: number;
54
+ /**
55
+ * Claims after which a document is failed instead of requeued. Bounded so a
56
+ * document that reliably kills the worker takes itself out of the queue
57
+ * rather than the queue out of service.
58
+ */
59
+ readonly indexMaxAttempts: number;
60
+ readonly rateSearchPerMin: number;
61
+ readonly rateIngestPerHour: number;
62
+ readonly maxDocumentBytes: number;
63
+ readonly auditRetentionDays: number;
64
+ readonly auditQueryText: boolean;
65
+ }
66
+ export declare class ConfigError extends Error {
67
+ readonly problems: readonly string[];
68
+ constructor(problems: readonly string[]);
69
+ }
70
+ type Env = Readonly<Record<string, string | undefined>>;
71
+ export declare function loadConfig(env?: Env): Config;
72
+ export {};
73
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,GAAG,EAAE,aAAa,GAAG,YAAY,CAAA;IAC1C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;IAEnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IACzC,QAAQ,CAAC,aAAa,EAAE,YAAY,GAAG,QAAQ,CAAA;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IAEzB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;IAEjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAE/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAEhC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IAExB;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAE3B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IAEjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IAEjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAA;CACjC;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;gBAExB,QAAQ,EAAE,SAAS,MAAM,EAAE;CAQxC;AAED,KAAK,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;AA8DvD,wBAAgB,UAAU,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAoFzD"}