@anchrd/intel-api 0.35.0 → 0.37.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/db/db-boards.js +28 -1
- package/dist/adapters/db/db-feed.d.ts +8 -18
- package/dist/adapters/db/db-feed.js +101 -39
- package/dist/adapters/db/db-flows.js +2 -1
- package/dist/adapters/db/db-grants.d.ts +10 -0
- package/dist/adapters/db/db-grants.js +14 -2
- package/dist/adapters/db/db.js +8 -1
- package/dist/boards/boards.d.ts +14 -0
- package/dist/boards/boards.js +110 -13
- package/dist/boards/boards.types.d.ts +7 -0
- package/dist/mcp/mcp.js +1 -1
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/package.json +2 -2
|
@@ -247,6 +247,18 @@ export function createBoardRepository(deps) {
|
|
|
247
247
|
* membership to anyone who can read it; what leaves this method cannot be turned back into who
|
|
248
248
|
* granted what to whom.
|
|
249
249
|
*
|
|
250
|
+
* ⚠️ **A grant can name an EMAIL instead of a user id, and both are real access** (#757). The
|
|
251
|
+
* first version of this counted only `principal_type = 'user'` and therefore showed nobody on a
|
|
252
|
+
* board shared by address. Measured on the installation on 2026-08-23: four of nine grants were
|
|
253
|
+
* `email` ones.
|
|
254
|
+
*
|
|
255
|
+
* ⚠️ **Not lowercased here, and that is measured rather than assumed.** The write path already
|
|
256
|
+
* normalises an address twice (`nodes.ts` before the write, `db-grants.ts` at the boundary), and
|
|
257
|
+
* the installation confirms it: every stored `email` grant is lowercase. A `lower()` here would
|
|
258
|
+
* be defence against data that cannot exist, and no test could tell it from a no-op. The side
|
|
259
|
+
* that DOES need folding is the directory hit, because gate stores what somebody typed at
|
|
260
|
+
* signup and its casing is not this repository's to decide.
|
|
261
|
+
*
|
|
250
262
|
* ⚠️ `verb = 'read'`, and that is not a detail. The verbs here are granted INDEPENDENTLY
|
|
251
263
|
* (ADR-0004 §2): `write` does not follow from `read` and `read` does not follow from `write`.
|
|
252
264
|
* Counting any verb would offer somebody who holds only `write` or `share` as an assignee for a
|
|
@@ -279,17 +291,32 @@ export function createBoardRepository(deps) {
|
|
|
279
291
|
AND grant_row.verb = 'read'
|
|
280
292
|
AND ${grantInForce}
|
|
281
293
|
)) AS principal_ids_json,
|
|
294
|
+
(SELECT json_group_array(principal_id)
|
|
295
|
+
FROM (
|
|
296
|
+
SELECT DISTINCT grant_row.principal_id
|
|
297
|
+
FROM node_grants grant_row
|
|
298
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
299
|
+
WHERE grant_row.principal_type = 'email'
|
|
300
|
+
AND grant_row.principal_id IS NOT NULL
|
|
301
|
+
AND grant_row.verb = 'read'
|
|
302
|
+
AND ${grantInForce}
|
|
303
|
+
)) AS principal_emails_json,
|
|
282
304
|
(SELECT COUNT(*)
|
|
283
305
|
FROM node_grants grant_row
|
|
284
306
|
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
285
307
|
WHERE grant_row.principal_type = 'organization'
|
|
286
308
|
AND grant_row.verb = 'read'
|
|
287
309
|
AND ${grantInForce}) AS organization_grants`)
|
|
288
|
-
|
|
310
|
+
// ⚠️ ONE `now` per `grantInForce`, and there are three of them since #757: the user
|
|
311
|
+
// grants, the email grants and the organization count. A missing binding is not a wrong
|
|
312
|
+
// answer here, it is `D1_ERROR: Wrong number of parameter bindings` — loud, which is the
|
|
313
|
+
// good case. Read the count off the statement, never off memory.
|
|
314
|
+
.bind(boardId, deps.now().toISOString(), deps.now().toISOString(), deps.now().toISOString())
|
|
289
315
|
.first();
|
|
290
316
|
return {
|
|
291
317
|
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
292
318
|
principalIds: JSON.parse(row?.principal_ids_json ?? "[]"),
|
|
319
|
+
principalEmails: JSON.parse(row?.principal_emails_json ?? "[]"),
|
|
293
320
|
organizationWide: (row?.organization_grants ?? 0) > 0,
|
|
294
321
|
};
|
|
295
322
|
},
|
|
@@ -1,23 +1,5 @@
|
|
|
1
1
|
import type { FeedRepository } from "../../feed/feed.types.js";
|
|
2
2
|
import type { D1Database } from "./db.types.js";
|
|
3
|
-
/**
|
|
4
|
-
* One page of the journal, newest first, cut down to the rows this actor may see.
|
|
5
|
-
*
|
|
6
|
-
* ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
|
|
7
|
-
* the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
|
|
8
|
-
* exactly when the node is. A second, similar-looking query beside the first is the drift that
|
|
9
|
-
* `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
|
|
10
|
-
*
|
|
11
|
-
* ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
|
|
12
|
-
* in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
|
|
13
|
-
* alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
|
|
14
|
-
* tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
|
|
15
|
-
*
|
|
16
|
-
* ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
|
|
17
|
-
* is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
|
|
18
|
-
* sort column is reversed together. Reversing one would cost a sort over the whole table, and that
|
|
19
|
-
* is invisible on the first pages and only hurts far down.
|
|
20
|
-
*/
|
|
21
3
|
export declare const feedPageQuery: string;
|
|
22
4
|
/**
|
|
23
5
|
* The folders above each of a page's nodes, and only the ones the actor may open.
|
|
@@ -32,6 +14,14 @@ export declare const feedPageQuery: string;
|
|
|
32
14
|
* and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
|
|
33
15
|
* and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
|
|
34
16
|
* `parent_id`, where a visited-set makes the cycle harmless.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
|
|
19
|
+
* its parent works only while the node exists, and the one event that most needs a trail is the one
|
|
20
|
+
* whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
|
|
21
|
+
* where the walk went anyway — and it is the only anchor a deletion has left.
|
|
22
|
+
*
|
|
23
|
+
* There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
|
|
24
|
+
* on the page, and each trail is rebuilt from its own `parentId` through that set.
|
|
35
25
|
*/
|
|
36
26
|
export declare const feedAncestorsQuery: (seeds: number) => string;
|
|
37
27
|
export declare function createFeedRepository(deps: {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { FeedAction } from "@anchrd/intel-contract/feed";
|
|
2
|
-
import { subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
const
|
|
1
|
+
import { FeedAction, feedKindOf, } from "@anchrd/intel-contract/feed";
|
|
2
|
+
import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
3
|
+
// The two kinds of resource the feed reads, split out of the one action list so the SQL can name
|
|
4
|
+
// each side by itself. Derived from the contract rather than written again here: one list that has
|
|
5
|
+
// to agree with another is one list too many.
|
|
6
|
+
const NODE_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "node");
|
|
7
|
+
const FLOW_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "flow");
|
|
8
|
+
const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
|
|
9
|
+
const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
|
|
7
10
|
function parseMetadata(raw) {
|
|
8
11
|
// The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
|
|
9
12
|
// `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
|
|
@@ -29,24 +32,72 @@ function parseMetadata(raw) {
|
|
|
29
32
|
* exactly when the node is. A second, similar-looking query beside the first is the drift that
|
|
30
33
|
* `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
|
|
31
34
|
*
|
|
35
|
+
* ⚠️ **Two events have no row left to check, and that is why the join reads `COALESCE`** (#645 for
|
|
36
|
+
* `node.purge`, #763 for `flows.purge`).
|
|
37
|
+
* After `node.purge` the row is gone, so `JOIN nodes` dropped the event for everybody — creation,
|
|
38
|
+
* change and archiving arrived, final deletion never did. It now falls back to the `parentId` the
|
|
39
|
+
* purge wrote into its own metadata, and the check runs against that surviving FOLDER.
|
|
40
|
+
*
|
|
41
|
+
* The fallback is deliberately narrow: `COALESCE(n.id, …)` prefers the node whenever it exists, so
|
|
42
|
+
* for every other event nothing changes at all. It is not "or the parent is allowed" — that would
|
|
43
|
+
* hand out every event in a readable folder about nodes inside it that are NOT readable.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ **The title comes from the metadata for the same reason** — there is no row to read it from —
|
|
46
|
+
* and events written before #645 carry no `parentId`, so they stay unreachable. That cannot be
|
|
47
|
+
* repaired: at a deleted row there is nothing left to look up where it hung.
|
|
48
|
+
*
|
|
32
49
|
* ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
|
|
33
50
|
* in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
|
|
34
51
|
* alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
|
|
35
52
|
* tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
|
|
36
53
|
*
|
|
37
|
-
* ⚠️ **Both columns descend, and that is what lets
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
54
|
+
* ⚠️ **Both columns descend, and that is what lets an index carry this.** SQLite reads an index
|
|
55
|
+
* backwards only when every sort column is reversed together. Reversing one would cost a sort over
|
|
56
|
+
* the whole table, and that is invisible on the first pages and only hurts far down.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
|
|
59
|
+
* The feed reads two kinds at once, and the older index leads with `resource_type`; under
|
|
60
|
+
* `IN ('node', 'flow')` — or, as written here, under two branches joined by `OR` — that column
|
|
61
|
+
* stops being an equality prefix, and the ordering falls off it. Measured before `0026` existed:
|
|
62
|
+
* `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
|
|
63
|
+
* `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **The unary `+` in front of each `resource_type` is load-bearing, not a typo.** It tells SQLite
|
|
66
|
+
* the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
|
|
67
|
+
* `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
|
|
68
|
+
* the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
|
|
69
|
+
* whole table. Measured both ways. Filtering on kind is cheap here (two values out of three);
|
|
70
|
+
* sorting the journal is not.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ **The two halves ask two DIFFERENT authorization questions, and neither may stand in for the
|
|
73
|
+
* other.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
|
|
74
|
+
* when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
|
|
75
|
+
* `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
|
|
76
|
+
* exist. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
|
|
41
77
|
*/
|
|
78
|
+
const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
|
|
79
|
+
const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
|
|
42
80
|
export const feedPageQuery = `${subtreeCte}
|
|
43
|
-
SELECT e.id, e.actor_id, e.action, e.resource_id,
|
|
44
|
-
n.
|
|
81
|
+
SELECT e.id, e.actor_id, e.action, e.resource_id,
|
|
82
|
+
COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) AS resource_title,
|
|
83
|
+
COALESCE(n.parent_id, flow.parent_id, json_extract(e.metadata_json, '$.parentId'))
|
|
84
|
+
AS resource_parent_id,
|
|
85
|
+
e.metadata_json, e.occurred_at
|
|
45
86
|
FROM audit_events e
|
|
46
|
-
JOIN
|
|
47
|
-
JOIN
|
|
48
|
-
WHERE
|
|
49
|
-
|
|
87
|
+
LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
|
|
88
|
+
LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
|
|
89
|
+
WHERE (
|
|
90
|
+
(
|
|
91
|
+
+e.resource_type = 'node'
|
|
92
|
+
AND e.action IN (${NODE_PLACEHOLDERS})
|
|
93
|
+
AND ${NODE_PARENT} IN (SELECT id FROM allowed)
|
|
94
|
+
)
|
|
95
|
+
OR (
|
|
96
|
+
+e.resource_type = 'flow'
|
|
97
|
+
AND e.action IN (${FLOW_PLACEHOLDERS})
|
|
98
|
+
AND ${flowInSubtreeOver(FLOW_PARENT)}
|
|
99
|
+
)
|
|
100
|
+
)
|
|
50
101
|
AND (? IS NULL OR e.actor_id = ?)
|
|
51
102
|
AND (
|
|
52
103
|
? IS NULL
|
|
@@ -68,19 +119,26 @@ export const feedPageQuery = `${subtreeCte}
|
|
|
68
119
|
* and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
|
|
69
120
|
* and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
|
|
70
121
|
* `parent_id`, where a visited-set makes the cycle harmless.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
|
|
124
|
+
* its parent works only while the node exists, and the one event that most needs a trail is the one
|
|
125
|
+
* whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
|
|
126
|
+
* where the walk went anyway — and it is the only anchor a deletion has left.
|
|
127
|
+
*
|
|
128
|
+
* There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
|
|
129
|
+
* on the page, and each trail is rebuilt from its own `parentId` through that set.
|
|
71
130
|
*/
|
|
72
131
|
export const feedAncestorsQuery = (seeds) => `${subtreeCte},
|
|
73
|
-
ancestry(
|
|
74
|
-
SELECT
|
|
75
|
-
FROM nodes
|
|
76
|
-
|
|
77
|
-
WHERE child.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
|
|
132
|
+
ancestry(id, parent_id, title) AS (
|
|
133
|
+
SELECT parent.id, parent.parent_id, parent.title
|
|
134
|
+
FROM nodes parent
|
|
135
|
+
WHERE parent.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
|
|
78
136
|
UNION
|
|
79
|
-
SELECT
|
|
137
|
+
SELECT grandparent.id, grandparent.parent_id, grandparent.title
|
|
80
138
|
FROM nodes grandparent
|
|
81
139
|
JOIN ancestry a ON a.parent_id = grandparent.id
|
|
82
140
|
)
|
|
83
|
-
SELECT a.
|
|
141
|
+
SELECT a.id, a.parent_id, a.title
|
|
84
142
|
FROM ancestry a
|
|
85
143
|
JOIN allowed ON allowed.id = a.id`;
|
|
86
144
|
/**
|
|
@@ -115,36 +173,40 @@ export function createFeedRepository(deps) {
|
|
|
115
173
|
return {
|
|
116
174
|
async listEvents(actor, query) {
|
|
117
175
|
const before = query.before ?? null;
|
|
176
|
+
// One reading of the clock for the whole call. Both guards measure grant expiry against it,
|
|
177
|
+
// and two `now()` calls could straddle the moment a grant runs out — a page whose flow half
|
|
178
|
+
// answers about a permission its node half no longer has.
|
|
179
|
+
const now = deps.now().toISOString();
|
|
118
180
|
const page = await deps.db
|
|
119
181
|
.prepare(feedPageQuery)
|
|
120
|
-
.bind(...subtreeBindings(actor, "read",
|
|
182
|
+
.bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
|
|
121
183
|
.all();
|
|
122
184
|
const rows = page.results ?? [];
|
|
123
185
|
if (rows.length === 0)
|
|
124
186
|
return { events: [] };
|
|
125
187
|
// One walk for the whole page rather than one per row. The set is at most the page size, and
|
|
126
188
|
// it is deduplicated because a busy morning on one document is exactly the case the feed
|
|
127
|
-
// shows unsummarised today.
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
]));
|
|
189
|
+
// shows unsummarised today. The trail is keyed by EVENT rather than by node: two events about
|
|
190
|
+
// one node share a trail, but a deleted node and a live one can carry the same parent.
|
|
191
|
+
const parentIds = [
|
|
192
|
+
...new Set(rows.map((row) => row.resource_parent_id).filter((id) => id !== null)),
|
|
193
|
+
];
|
|
194
|
+
const ancestorRows = parentIds.length === 0
|
|
195
|
+
? []
|
|
196
|
+
: ((await deps.db
|
|
197
|
+
.prepare(feedAncestorsQuery(parentIds.length))
|
|
198
|
+
.bind(...subtreeBindings(actor, "read", now), ...parentIds)
|
|
199
|
+
.all()).results ?? []);
|
|
200
|
+
const trails = new Map(rows.map((row) => [row.id, trailFor(row.resource_parent_id, ancestorRows)]));
|
|
139
201
|
const events = rows.map((row) => ({
|
|
140
202
|
id: row.id,
|
|
141
203
|
actorId: row.actor_id,
|
|
142
204
|
// Narrowed at the door by the SQL `IN`, so the cast names what the query already
|
|
143
205
|
// guarantees rather than trusting the column.
|
|
144
206
|
action: row.action,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
path: trails.get(row.
|
|
207
|
+
resourceId: row.resource_id,
|
|
208
|
+
resourceTitle: row.resource_title,
|
|
209
|
+
path: trails.get(row.id) ?? [],
|
|
148
210
|
metadata: parseMetadata(row.metadata_json),
|
|
149
211
|
occurredAt: row.occurred_at,
|
|
150
212
|
}));
|
|
@@ -532,7 +532,8 @@ export function createFlowRepository(deps) {
|
|
|
532
532
|
deps.db
|
|
533
533
|
.prepare(`INSERT INTO audit_events (
|
|
534
534
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
535
|
-
) SELECT ?, ?, 'flows.purge', 'flow', f.id,
|
|
535
|
+
) SELECT ?, ?, 'flows.purge', 'flow', f.id,
|
|
536
|
+
json_object('title', f.title, 'parentId', f.parent_id), ?
|
|
536
537
|
FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
|
|
537
538
|
.bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
|
|
538
539
|
// The runs are this flow's history: the steps hang off the runs, the runs off the flow.
|
|
@@ -106,5 +106,15 @@ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb
|
|
|
106
106
|
* different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
|
|
107
107
|
* fails on: the query still runs and quietly hands out the wrong list.
|
|
108
108
|
*/
|
|
109
|
+
/**
|
|
110
|
+
* ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
|
|
111
|
+
* alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
|
|
112
|
+
* is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
|
|
113
|
+
* the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
|
|
114
|
+
* second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
|
|
115
|
+
* expressions with similar names are two different questions, and the one that goes stale is the
|
|
116
|
+
* copy.
|
|
117
|
+
*/
|
|
118
|
+
export declare function flowInSubtreeOver(parentExpression: string): string;
|
|
109
119
|
export declare const flowInSubtree: string;
|
|
110
120
|
export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
@@ -233,12 +233,24 @@ export function flowVerbBindings(flowId, actor, verb, now) {
|
|
|
233
233
|
* different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
|
|
234
234
|
* fails on: the query still runs and quietly hands out the wrong list.
|
|
235
235
|
*/
|
|
236
|
-
|
|
236
|
+
/**
|
|
237
|
+
* ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
|
|
238
|
+
* alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
|
|
239
|
+
* is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
|
|
240
|
+
* the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
|
|
241
|
+
* second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
|
|
242
|
+
* expressions with similar names are two different questions, and the one that goes stale is the
|
|
243
|
+
* copy.
|
|
244
|
+
*/
|
|
245
|
+
export function flowInSubtreeOver(parentExpression) {
|
|
246
|
+
return `(
|
|
237
247
|
? = 1
|
|
238
248
|
OR flow.owner_id = ?
|
|
239
|
-
OR
|
|
249
|
+
OR ${parentExpression} IN (SELECT id FROM allowed)
|
|
240
250
|
OR ${flowGrantExists("flow.id")}
|
|
241
251
|
)`;
|
|
252
|
+
}
|
|
253
|
+
export const flowInSubtree = flowInSubtreeOver("flow.parent_id");
|
|
242
254
|
export function flowInSubtreeBindings(actor, verb, now) {
|
|
243
255
|
return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
|
|
244
256
|
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -904,11 +904,18 @@ export function createNodeRepository(deps) {
|
|
|
904
904
|
// ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
|
|
905
905
|
// `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
|
|
906
906
|
// not book one.
|
|
907
|
+
//
|
|
908
|
+
// ⚠️ **`parentId` travels beside the title, and it is what makes this row READABLE
|
|
909
|
+
// afterwards** (#645). Visibility everywhere else walks `nodes` from the row itself, and
|
|
910
|
+
// after this batch that row is gone — so until now the event was unreachable for everybody,
|
|
911
|
+
// admin included. The surviving FOLDER answers instead. It is written here rather than
|
|
912
|
+
// derived later for the same reason the title is: after the delete there is nothing left to
|
|
913
|
+
// look either of them up from (`destructive.md`).
|
|
907
914
|
deps.db
|
|
908
915
|
.prepare(`INSERT INTO audit_events (
|
|
909
916
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
910
917
|
) SELECT ?, ?, 'node.purge', 'node', n.id,
|
|
911
|
-
json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
|
|
918
|
+
json_patch(?, json_object('title', n.title, 'kind', n.kind, 'parentId', n.parent_id)), ?
|
|
912
919
|
FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
913
920
|
.bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
|
|
914
921
|
deps.db
|
package/dist/boards/boards.d.ts
CHANGED
|
@@ -9,22 +9,36 @@ import type { BoardDeps, BoardService } from "./boards.types.js";
|
|
|
9
9
|
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
10
10
|
* configured — one made over MCP, or one whose seeding did not land.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* ⚠️ **The four start coloured, and the colours mean something** (Jack's decision 2026-08-22): grey
|
|
14
|
+
* for what is only lying there, yellow for what is ready, orange for what is being worked on, green
|
|
15
|
+
* for what is done. That order is the one people already read without a legend, which is the whole
|
|
16
|
+
* reason a default is worth having — a board that starts grey teaches nothing, and one that starts
|
|
17
|
+
* in arbitrary colours teaches something false.
|
|
18
|
+
*
|
|
19
|
+
* They are a suggestion, not a setting: every one of them can be overwritten, and a board that has
|
|
20
|
+
* been recoloured keeps its own values.
|
|
21
|
+
*/
|
|
12
22
|
export declare const DEFAULT_COLUMNS: readonly [{
|
|
13
23
|
readonly id: "backlog";
|
|
14
24
|
readonly title: "Backlog";
|
|
15
25
|
readonly terminal: false;
|
|
26
|
+
readonly color: "#9ca3af";
|
|
16
27
|
}, {
|
|
17
28
|
readonly id: "todo";
|
|
18
29
|
readonly title: "Offen";
|
|
19
30
|
readonly terminal: false;
|
|
31
|
+
readonly color: "#eab308";
|
|
20
32
|
}, {
|
|
21
33
|
readonly id: "doing";
|
|
22
34
|
readonly title: "Läuft";
|
|
23
35
|
readonly terminal: false;
|
|
36
|
+
readonly color: "#f97316";
|
|
24
37
|
}, {
|
|
25
38
|
readonly id: "done";
|
|
26
39
|
readonly title: "Fertig";
|
|
27
40
|
readonly terminal: true;
|
|
41
|
+
readonly color: "#22c55e";
|
|
28
42
|
}];
|
|
29
43
|
export declare const FIRST_DEFAULT_COLUMN: "backlog";
|
|
30
44
|
export declare function createBoards(deps: BoardDeps): BoardService;
|
package/dist/boards/boards.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, DEFAULT_COLUMN_COLOR } from "@anchrd/intel-contract/board";
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
3
|
/**
|
|
4
4
|
* What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
@@ -10,11 +10,21 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
|
10
10
|
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
11
11
|
* configured — one made over MCP, or one whose seeding did not land.
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ **The four start coloured, and the colours mean something** (Jack's decision 2026-08-22): grey
|
|
15
|
+
* for what is only lying there, yellow for what is ready, orange for what is being worked on, green
|
|
16
|
+
* for what is done. That order is the one people already read without a legend, which is the whole
|
|
17
|
+
* reason a default is worth having — a board that starts grey teaches nothing, and one that starts
|
|
18
|
+
* in arbitrary colours teaches something false.
|
|
19
|
+
*
|
|
20
|
+
* They are a suggestion, not a setting: every one of them can be overwritten, and a board that has
|
|
21
|
+
* been recoloured keeps its own values.
|
|
22
|
+
*/
|
|
13
23
|
export const DEFAULT_COLUMNS = [
|
|
14
|
-
{ id: "backlog", title: "Backlog", terminal: false },
|
|
15
|
-
{ id: "todo", title: "Offen", terminal: false },
|
|
16
|
-
{ id: "doing", title: "Läuft", terminal: false },
|
|
17
|
-
{ id: "done", title: "Fertig", terminal: true },
|
|
24
|
+
{ id: "backlog", title: "Backlog", terminal: false, color: DEFAULT_COLUMN_COLOR },
|
|
25
|
+
{ id: "todo", title: "Offen", terminal: false, color: "#eab308" },
|
|
26
|
+
{ id: "doing", title: "Läuft", terminal: false, color: "#f97316" },
|
|
27
|
+
{ id: "done", title: "Fertig", terminal: true, color: "#22c55e" },
|
|
18
28
|
];
|
|
19
29
|
// The column a card lands in when nobody said and the board has none configured. One export rather
|
|
20
30
|
// than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
|
|
@@ -24,6 +34,26 @@ export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
|
|
|
24
34
|
// is a listing under a different name — the same floor gate holds at its own door, stated here so
|
|
25
35
|
// this surface keeps its own promise (#700).
|
|
26
36
|
const MINIMUM_QUERY = 2;
|
|
37
|
+
// How many people a picker offers before anybody types. Three is a suggestion, not a directory: a
|
|
38
|
+
// longer list is one somebody reads instead of typing the name they already know (#757).
|
|
39
|
+
const SUGGESTIONS = 3;
|
|
40
|
+
/**
|
|
41
|
+
* Whether one person from the directory reaches this board (#700, #757).
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **Two ways in, and the second one cost a release to notice.** A grant names either a user id or
|
|
44
|
+
* an EMAIL, and both are real access. Counting only ids showed nobody on a board shared by address,
|
|
45
|
+
* which is how four of the nine grants on the reference installation are written.
|
|
46
|
+
*
|
|
47
|
+
* The address is compared lowercased on both sides: a grant written `Anton@…` and a directory hit
|
|
48
|
+
* that says `anton@…` are the same person, and a case-sensitive compare would quietly disagree.
|
|
49
|
+
*/
|
|
50
|
+
function reachedBy(access, person) {
|
|
51
|
+
if (access.ownerIds.includes(person.id))
|
|
52
|
+
return true;
|
|
53
|
+
if (access.principalIds.includes(person.id))
|
|
54
|
+
return true;
|
|
55
|
+
return access.principalEmails.includes(person.email.toLowerCase());
|
|
56
|
+
}
|
|
27
57
|
export function createBoards(deps) {
|
|
28
58
|
/**
|
|
29
59
|
* ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
|
|
@@ -84,6 +114,61 @@ export function createBoards(deps) {
|
|
|
84
114
|
return;
|
|
85
115
|
throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
|
|
86
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* The people this board can be handed to, without a search term (#757).
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ **Named, not enumerated.** Gate's directory answers searches, not listings, so what can be
|
|
121
|
+
* offered here is exactly the set this repository already knows by name: the owners, the user
|
|
122
|
+
* grants, and the addresses of the email grants. An organization-wide board therefore suggests
|
|
123
|
+
* whatever explicit grants it also has and nothing more — "everybody" is not a list anyone can
|
|
124
|
+
* produce, and pretending otherwise would mean asking gate to hand over its directory.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ **The cap is visible.** `more` says how many are left over, so the picker can say "and 4
|
|
127
|
+
* more" instead of quietly showing three and looking complete.
|
|
128
|
+
*/
|
|
129
|
+
const suggest = async (boardId) => {
|
|
130
|
+
if (!deps.directory)
|
|
131
|
+
return { items: [], more: 0 };
|
|
132
|
+
const access = await deps.boards.effectiveAccess(boardId);
|
|
133
|
+
const ids = [...new Set([...access.ownerIds, ...access.principalIds])];
|
|
134
|
+
// One call for every id at once. The addresses need one lookup each, because gate resolves ids
|
|
135
|
+
// and searches text and there is no third door — so only as many as it takes to FILL the cap.
|
|
136
|
+
const byId = ids.length === 0 ? [] : await deps.directory.resolve(ids);
|
|
137
|
+
const knownEmails = new Set(byId.map((person) => person.email.toLowerCase()));
|
|
138
|
+
/**
|
|
139
|
+
* ⚠️ **The count and the list are two different things, and the count comes FIRST.**
|
|
140
|
+
*
|
|
141
|
+
* An earlier version stopped looking after the cap and then reported the leftovers it happened
|
|
142
|
+
* to have — so a board with ten address grants said "and 1 more" instead of "and 7 more". The
|
|
143
|
+
* cap was visible and its number was wrong, which is worse than no number: it is a number
|
|
144
|
+
* somebody believes.
|
|
145
|
+
*
|
|
146
|
+
* Counted here without naming anybody: every address that is not already one of the resolved
|
|
147
|
+
* people is one more person who reaches this board. Whether gate can name them does not change
|
|
148
|
+
* how many there are.
|
|
149
|
+
*/
|
|
150
|
+
const unnamedAddresses = access.principalEmails.filter((address) => !knownEmails.has(address));
|
|
151
|
+
const total = byId.length + unnamedAddresses.length;
|
|
152
|
+
/**
|
|
153
|
+
* ⚠️ **The cap counts LOOKUPS, not names found**, and the difference is a real hole. An address
|
|
154
|
+
* gate cannot name never grows `named`, so a bound on the names would never trigger: a board
|
|
155
|
+
* with a hundred address grants and no matching accounts would fire a hundred sequential calls
|
|
156
|
+
* to gate to open one menu. The test that sets six unnameable addresses walks exactly that path.
|
|
157
|
+
*/
|
|
158
|
+
const named = [...byId];
|
|
159
|
+
let lookups = 0;
|
|
160
|
+
for (const address of unnamedAddresses) {
|
|
161
|
+
if (named.length >= SUGGESTIONS || lookups >= SUGGESTIONS)
|
|
162
|
+
break;
|
|
163
|
+
lookups += 1;
|
|
164
|
+
const found = await deps.directory.search(address);
|
|
165
|
+
const exact = found.find((person) => person.email.toLowerCase() === address);
|
|
166
|
+
if (exact !== undefined)
|
|
167
|
+
named.push(exact);
|
|
168
|
+
}
|
|
169
|
+
const items = named.slice(0, SUGGESTIONS);
|
|
170
|
+
return { items, more: Math.max(0, total - items.length) };
|
|
171
|
+
};
|
|
87
172
|
return {
|
|
88
173
|
async get(actor, input) {
|
|
89
174
|
return await viewOrRefuse(actor, input);
|
|
@@ -308,7 +393,7 @@ export function createBoards(deps) {
|
|
|
308
393
|
// read gives, so a board one may not see stays indistinguishable from one that is not there.
|
|
309
394
|
await columnsOrRefuse(actor, input.boardId, "read");
|
|
310
395
|
if (!deps.directory)
|
|
311
|
-
return { items: [] };
|
|
396
|
+
return { items: [], more: 0 };
|
|
312
397
|
/**
|
|
313
398
|
* ⚠️ ENFORCED HERE, not only described. The contract's `query` says a single character
|
|
314
399
|
* answers empty, and until this line that sentence was true only because gate happens to
|
|
@@ -317,19 +402,31 @@ export function createBoards(deps) {
|
|
|
317
402
|
* It is also the cheaper answer: a letter that can only ever produce a listing does not
|
|
318
403
|
* need to travel to gate first to be refused there.
|
|
319
404
|
*/
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
405
|
+
const query = input.query.trim();
|
|
406
|
+
/**
|
|
407
|
+
* ⚠️ **Nothing typed is a QUESTION, not a refusal** (#757). The set of people who reach this
|
|
408
|
+
* board is short and known, so it can be named without a search term — and a picker that
|
|
409
|
+
* opens empty teaches the reader that there is nobody, which is the opposite of what it
|
|
410
|
+
* means.
|
|
411
|
+
*
|
|
412
|
+
* A single character stays refused, and the reason is unchanged: a letter is not a search, it
|
|
413
|
+
* is a listing under a different name. The two cases differ because one names a bounded set
|
|
414
|
+
* and the other asks the directory to filter one.
|
|
415
|
+
*/
|
|
416
|
+
if (query.length === 0)
|
|
417
|
+
return await suggest(input.boardId);
|
|
418
|
+
if (query.length < MINIMUM_QUERY)
|
|
419
|
+
return { items: [], more: 0 };
|
|
420
|
+
const hits = await deps.directory.search(query);
|
|
323
421
|
if (hits.length === 0)
|
|
324
|
-
return { items: [] };
|
|
422
|
+
return { items: [], more: 0 };
|
|
325
423
|
const access = await deps.boards.effectiveAccess(input.boardId);
|
|
326
424
|
// A grant to the organization means everybody reaches the board, so there is nothing left to
|
|
327
425
|
// narrow — and narrowing anyway would produce an empty picker on exactly the boards that are
|
|
328
426
|
// shared with everyone, which is most of them.
|
|
329
427
|
if (access.organizationWide)
|
|
330
|
-
return { items: hits };
|
|
331
|
-
|
|
332
|
-
return { items: hits.filter((hit) => reaches.has(hit.id)) };
|
|
428
|
+
return { items: hits, more: 0 };
|
|
429
|
+
return { items: hits.filter((hit) => reachedBy(access, hit)), more: 0 };
|
|
333
430
|
},
|
|
334
431
|
/**
|
|
335
432
|
* What the people already recorded on cards are called (#258, #700).
|
|
@@ -56,6 +56,7 @@ export interface BoardRepository {
|
|
|
56
56
|
effectiveAccess(boardId: string): Promise<{
|
|
57
57
|
ownerIds: string[];
|
|
58
58
|
principalIds: string[];
|
|
59
|
+
principalEmails: string[];
|
|
59
60
|
organizationWide: boolean;
|
|
60
61
|
}>;
|
|
61
62
|
}
|
|
@@ -101,8 +102,14 @@ export interface BoardService {
|
|
|
101
102
|
update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
|
|
102
103
|
createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
|
|
103
104
|
updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ `more` says how many people reach this board beyond the ones named, so a capped list can say
|
|
107
|
+
* so instead of looking complete (#757). It is `0` on a search answer, where the cap belongs to
|
|
108
|
+
* gate and not to this door.
|
|
109
|
+
*/
|
|
104
110
|
searchAssignees(actor: Actor, input: BoardAssigneeSearchInput): Promise<{
|
|
105
111
|
items: BoardAssignee[];
|
|
112
|
+
more: number;
|
|
106
113
|
}>;
|
|
107
114
|
resolveAssignees(actor: Actor, input: BoardAssigneeResolveInput): Promise<{
|
|
108
115
|
items: BoardAssignee[];
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -192,7 +192,7 @@ export async function handleMcp(request, deps) {
|
|
|
192
192
|
// `list`, and a filter does not change which verb a call is.
|
|
193
193
|
server.registerTool("board_assignee_list", {
|
|
194
194
|
title: "Who a card on this board can be given to",
|
|
195
|
-
description: "Find the people you may set as assignee on a card of this board
|
|
195
|
+
description: "Find the people you may set as assignee on a card of this board. Only people who can actually open THIS board are offered, so the same query against two boards can give two different answers. Leave the query EMPTY to get up to three of them without searching, plus a count of how many more there are. One character answers empty rather than everybody; from two on it filters. Use board_assignee_resolve to name somebody already on a card.",
|
|
196
196
|
inputSchema: BoardAssigneeSearchInput,
|
|
197
197
|
annotations: {
|
|
198
198
|
title: "Who a card on this board can be given to",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- The feed reads both kinds at once — nodes and flows — and `audit_events_feed_idx` cannot serve
|
|
2
|
+
-- that. It leads with `resource_type` because every reader until now named exactly one kind, so for
|
|
3
|
+
-- a query saying `resource_type IN ('node', 'flow')` that column is no longer an equality prefix but
|
|
4
|
+
-- an ordinary filter, and the ordering falls off the index.
|
|
5
|
+
--
|
|
6
|
+
-- ⚠️ Measured against the real `feedPageQuery` before this file existed, not assumed
|
|
7
|
+
-- (anchrd/intel#763):
|
|
8
|
+
--
|
|
9
|
+
-- SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)
|
|
10
|
+
-- … | USE TEMP B-TREE FOR ORDER BY
|
|
11
|
+
--
|
|
12
|
+
-- That last line is a sorting pass over the whole table. It costs nothing on the first page and
|
|
13
|
+
-- everything far down — which is exactly where an endless scroll goes.
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` gives: two events
|
|
16
|
+
-- written in the same millisecond are the normal case inside one batch, and without the tie-break
|
|
17
|
+
-- on `id` a reader skips the second one with no error and no log.
|
|
18
|
+
--
|
|
19
|
+
-- ⚠️ This does NOT replace `audit_events_feed_idx`. That one still serves `audit_list`, which names
|
|
20
|
+
-- one kind and is the contract `anchrd/signals` builds on; dropping it would put a sorting pass into
|
|
21
|
+
-- the reader that has none today. Two readers, two shapes, two indexes.
|
|
22
|
+
CREATE INDEX audit_events_time_idx
|
|
23
|
+
ON audit_events(occurred_at, id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.25.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.29.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|