@anchrd/intel-api 0.3.1 → 0.3.2

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.
@@ -0,0 +1,47 @@
1
+ import type { ResourceVerb } from "@anchrd/intel-contract";
2
+ export interface GrantActor {
3
+ id: string;
4
+ email: string;
5
+ isAdmin?: boolean;
6
+ }
7
+ export declare const subtreeCte: string;
8
+ export declare function subtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
9
+ /**
10
+ * Both walks in one statement: what the actor may open, and what they may run. Its bindings are
11
+ * `subtreeBindings(actor, "read", now)` followed by `subtreeBindings(actor, "execute", now)`, in
12
+ * that order.
13
+ */
14
+ export declare const readableOrRunnableCte: string;
15
+ /**
16
+ * A flow the actor may open *or* may run, for a statement carrying `readableOrRunnableCte`. It is
17
+ * the callable rule of ADR-0004 §2/§3 — `execute` without `read` is the library, a building block
18
+ * anyone may run and few may open — asked of a whole list of flows at once instead of one flow at a
19
+ * time. Its bindings follow the two CTEs' and are `flowInSubtreeBindings(actor)`.
20
+ */
21
+ export declare const flowCallable = "(\n ? = 1\n OR flow.owner_id = ?\n OR flow.parent_id IN (SELECT id FROM readable)\n OR flow.parent_id IN (SELECT id FROM runnable)\n)";
22
+ /**
23
+ * The point check for one Knowledge node: the node itself and every ancestor above it. Cheaper than
24
+ * the subtree walk and the same answer, because a grant reaches down and never sideways.
25
+ *
26
+ * ⚠️ `UNION`, never `UNION ALL`. The service refuses to move a node into its own descendant, but a
27
+ * cycle in `parent_id` can still reach the table — a restore, a repair, a future writer. `UNION ALL`
28
+ * would walk such a ring without end, and this query runs on the authorization path of every
29
+ * request, so the damage lands in the database rather than in one tab. `UNION` folds the repeated
30
+ * row away and the recursion stops on its own.
31
+ */
32
+ export declare const nodeVerbQuery: string;
33
+ export declare function nodeVerbBindings(nodeId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
34
+ /**
35
+ * The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
36
+ * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a Knowledge node's
37
+ * owner keeps theirs — otherwise a flow at the root of the tree would be unreachable by the person
38
+ * who created it. `UNION` for the same reason as above.
39
+ */
40
+ export declare const flowVerbQuery: string;
41
+ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
42
+ /**
43
+ * The predicate a query over `flows` uses when it already carries `subtreeCte` for the same verb.
44
+ * Its bindings follow the CTE's.
45
+ */
46
+ export declare const flowInSubtree = "(? = 1 OR flow.owner_id = ? OR flow.parent_id IN (SELECT id FROM allowed))";
47
+ export declare function flowInSubtreeBindings(actor: GrantActor): unknown[];
@@ -0,0 +1,125 @@
1
+ const principalMatch = `(
2
+ (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
3
+ OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
4
+ OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
5
+ )`;
6
+ function grantExists(nodeColumn) {
7
+ return `EXISTS (
8
+ SELECT 1 FROM tree_grants grant_row
9
+ WHERE grant_row.node_id = ${nodeColumn}
10
+ AND ${principalMatch}
11
+ AND grant_row.verb = ?
12
+ AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
13
+ )`;
14
+ }
15
+ // Bindings for one `grantExists`: principal (twice), verb, and the moment expiry is measured
16
+ // against. Callers concatenate these in SQL order; the helpers below say which order that is.
17
+ function grantBindings(actor, verb, now) {
18
+ return [actor.id, actor.email, verb, now];
19
+ }
20
+ /**
21
+ * Seeds every node the actor reaches for one verb and walks downwards, which is what "a grant
22
+ * applies to everything beneath it" means in SQL. The name is a parameter because one statement can
23
+ * need two of these — asking `read` and `execute` at once is what turns "one query per callee" into
24
+ * one query (#30) — and a second walk written out by hand would be a second copy of the rule.
25
+ */
26
+ function subtreeWalk(name) {
27
+ return `${name}(id, parent_id) AS (
28
+ SELECT seed.id, seed.parent_id
29
+ FROM knowledge_nodes seed
30
+ WHERE ? = 1
31
+ OR seed.owner_id = ?
32
+ OR ${grantExists("seed.id")}
33
+ UNION
34
+ SELECT child.id, child.parent_id
35
+ FROM knowledge_nodes child
36
+ JOIN ${name} parent ON child.parent_id = parent.id
37
+ )`;
38
+ }
39
+ // The walk under the name every single-verb statement already uses. Callers append their own SELECT
40
+ // and may append further CTEs with a comma.
41
+ export const subtreeCte = `WITH RECURSIVE ${subtreeWalk("allowed")}`;
42
+ export function subtreeBindings(actor, verb, now) {
43
+ return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
44
+ }
45
+ /**
46
+ * Both walks in one statement: what the actor may open, and what they may run. Its bindings are
47
+ * `subtreeBindings(actor, "read", now)` followed by `subtreeBindings(actor, "execute", now)`, in
48
+ * that order.
49
+ */
50
+ export const readableOrRunnableCte = `WITH RECURSIVE ${subtreeWalk("readable")},
51
+ ${subtreeWalk("runnable")}`;
52
+ /**
53
+ * A flow the actor may open *or* may run, for a statement carrying `readableOrRunnableCte`. It is
54
+ * the callable rule of ADR-0004 §2/§3 — `execute` without `read` is the library, a building block
55
+ * anyone may run and few may open — asked of a whole list of flows at once instead of one flow at a
56
+ * time. Its bindings follow the two CTEs' and are `flowInSubtreeBindings(actor)`.
57
+ */
58
+ export const flowCallable = `(
59
+ ? = 1
60
+ OR flow.owner_id = ?
61
+ OR flow.parent_id IN (SELECT id FROM readable)
62
+ OR flow.parent_id IN (SELECT id FROM runnable)
63
+ )`;
64
+ /**
65
+ * The point check for one Knowledge node: the node itself and every ancestor above it. Cheaper than
66
+ * the subtree walk and the same answer, because a grant reaches down and never sideways.
67
+ *
68
+ * ⚠️ `UNION`, never `UNION ALL`. The service refuses to move a node into its own descendant, but a
69
+ * cycle in `parent_id` can still reach the table — a restore, a repair, a future writer. `UNION ALL`
70
+ * would walk such a ring without end, and this query runs on the authorization path of every
71
+ * request, so the damage lands in the database rather than in one tab. `UNION` folds the repeated
72
+ * row away and the recursion stops on its own.
73
+ */
74
+ export const nodeVerbQuery = `WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
75
+ SELECT id, parent_id, owner_id FROM knowledge_nodes WHERE id = ?
76
+ UNION
77
+ SELECT parent.id, parent.parent_id, parent.owner_id
78
+ FROM knowledge_nodes parent
79
+ JOIN ancestors child ON child.parent_id = parent.id
80
+ )
81
+ SELECT 1 AS allowed
82
+ FROM ancestors
83
+ WHERE ? = 1
84
+ OR owner_id = ?
85
+ OR ${grantExists("ancestors.id")}
86
+ LIMIT 1`;
87
+ export function nodeVerbBindings(nodeId, actor, verb, now) {
88
+ return [nodeId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
89
+ }
90
+ /**
91
+ * The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
92
+ * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a Knowledge node's
93
+ * owner keeps theirs — otherwise a flow at the root of the tree would be unreachable by the person
94
+ * who created it. `UNION` for the same reason as above.
95
+ */
96
+ export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
97
+ SELECT folder.id, folder.parent_id
98
+ FROM knowledge_nodes folder
99
+ JOIN flows flow ON flow.parent_id = folder.id
100
+ WHERE flow.id = ?
101
+ UNION
102
+ SELECT parent.id, parent.parent_id
103
+ FROM knowledge_nodes parent
104
+ JOIN ancestors child ON child.parent_id = parent.id
105
+ )
106
+ SELECT 1 AS allowed
107
+ FROM flows flow
108
+ WHERE flow.id = ?
109
+ AND (
110
+ ? = 1
111
+ OR flow.owner_id = ?
112
+ OR EXISTS (SELECT 1 FROM ancestors WHERE ${grantExists("ancestors.id")})
113
+ )
114
+ LIMIT 1`;
115
+ export function flowVerbBindings(flowId, actor, verb, now) {
116
+ return [flowId, flowId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
117
+ }
118
+ /**
119
+ * The predicate a query over `flows` uses when it already carries `subtreeCte` for the same verb.
120
+ * Its bindings follow the CTE's.
121
+ */
122
+ export const flowInSubtree = `(? = 1 OR flow.owner_id = ? OR flow.parent_id IN (SELECT id FROM allowed))`;
123
+ export function flowInSubtreeBindings(actor) {
124
+ return [actor.isAdmin ? 1 : 0, actor.id];
125
+ }
@@ -1,9 +1,9 @@
1
- function mapTarget(row) {
1
+ function mapTarget(row, contentKeys) {
2
2
  return {
3
3
  nodeId: row.node_id,
4
4
  versionId: row.version_id,
5
5
  title: row.title,
6
- contentKey: row.content_key,
6
+ contentKeys,
7
7
  mediaType: row.media_type,
8
8
  kind: row.kind,
9
9
  updatedAt: row.updated_at,
@@ -20,7 +20,18 @@ export function createKnowledgeIndexRepository(db) {
20
20
  WHERE v.id = ? AND n.current_version_id = v.id AND n.archived_at IS NULL`)
21
21
  .bind(versionId)
22
22
  .first();
23
- return row ? mapTarget(row) : null;
23
+ if (!row)
24
+ return null;
25
+ if (row.kind !== "table")
26
+ return mapTarget(row, [row.content_key]);
27
+ // A table's rows live in every version, so indexing the newest one alone would make a search
28
+ // find only what the last append added (#40).
29
+ const segments = await db
30
+ .prepare(`SELECT content_key FROM knowledge_versions
31
+ WHERE node_id = ? ORDER BY sequence`)
32
+ .bind(row.node_id)
33
+ .all();
34
+ return mapTarget(row, (segments.results ?? []).map((segment) => segment.content_key));
24
35
  },
25
36
  async replace(target, content) {
26
37
  await db.batch([