@anchrd/intel-api 0.27.0 → 0.29.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/cloudflare/cloudflare.js +38 -0
- package/dist/adapters/db/db-audit.d.ts +34 -0
- package/dist/adapters/db/db-audit.js +89 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +239 -0
- package/dist/adapters/db/db.js +38 -2
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +72 -0
- package/dist/audit/audit.types.d.ts +22 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/boards/boards.d.ts +30 -0
- package/dist/boards/boards.js +288 -0
- package/dist/boards/boards.types.d.ts +76 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/http/http.js +67 -0
- package/dist/http/http.types.d.ts +4 -0
- package/dist/intel/intel.js +4 -0
- package/dist/intel/intel.types.d.ts +4 -0
- package/dist/mcp/mcp.js +72 -0
- package/dist/mcp/mcp.types.d.ts +4 -0
- package/dist/nodes/nodes.js +224 -9
- package/dist/nodes/nodes.types.d.ts +39 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/package.json +2 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createGateClient } from "@anchrd/gate-sdk";
|
|
2
2
|
import { serverOf } from "@anchrd/intel-contract/tool";
|
|
3
3
|
import { ulid } from "ulid";
|
|
4
|
+
import { createAudit } from "../../audit/audit.js";
|
|
4
5
|
import { createBrowserAuth } from "../../auth/auth.js";
|
|
6
|
+
import { createBoards, FIRST_DEFAULT_COLUMN } from "../../boards/boards.js";
|
|
5
7
|
import { createBundle } from "../../bundle/bundle.js";
|
|
6
8
|
import { createFlows } from "../../flows/flows.js";
|
|
7
9
|
import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
|
|
@@ -12,6 +14,8 @@ import { sha256Hex } from "../../shared/sha256/sha256.js";
|
|
|
12
14
|
import { createTools } from "../../tools/tools.js";
|
|
13
15
|
import { createContentStore } from "../content/content.js";
|
|
14
16
|
import { createNodeRepository } from "../db/db.js";
|
|
17
|
+
import { createAuditRepository } from "../db/db-audit.js";
|
|
18
|
+
import { createBoardRepository } from "../db/db-boards.js";
|
|
15
19
|
import { createFlowRepository } from "../db/db-flows.js";
|
|
16
20
|
import { createNodeIndexRepository } from "../db/db-indexing.js";
|
|
17
21
|
import { createOAuthClientStore } from "../db/db-oauth.js";
|
|
@@ -63,6 +67,8 @@ export default {
|
|
|
63
67
|
// from outside it. The repository knows no service, so this stays one direction of dependency.
|
|
64
68
|
const flowRepository = createFlowRepository({ db: env.DB, now });
|
|
65
69
|
const nodeRepository = createNodeRepository({ db: env.DB, now });
|
|
70
|
+
const auditRepository = createAuditRepository({ db: env.DB, now });
|
|
71
|
+
const boardRepository = createBoardRepository({ db: env.DB, now });
|
|
66
72
|
const contentStore = createContentStore(env.CONTENT);
|
|
67
73
|
const gate = createGateClient({ url: env.GATE_URL, serviceKey: env.GATE_SERVICE_KEY });
|
|
68
74
|
const oauth = createOpenId({
|
|
@@ -114,6 +120,36 @@ export default {
|
|
|
114
120
|
content: contentStore,
|
|
115
121
|
id: ulid,
|
|
116
122
|
now,
|
|
123
|
+
// ⚠️ The REPOSITORY, not the board service — that is what keeps this from being a cycle. The
|
|
124
|
+
// board service needs the node service to create a task; the node service needs only the two
|
|
125
|
+
// reads below to file one.
|
|
126
|
+
// ⚠️ `"write"` for the same reason as below: this answers "may this actor file a card here",
|
|
127
|
+
// not "may they look at it". Reading it with `"read"` would let somebody nest a card under a
|
|
128
|
+
// task on a board they may only look at.
|
|
129
|
+
boardOfTask: async (who, taskId) => (await boardRepository.taskRow(who, taskId, "write"))?.boardId ?? null,
|
|
130
|
+
attachToBoard: async (who, taskId, boardId, occurredAt) => {
|
|
131
|
+
// ⚠️ `"write"` — the actor is filing a task on this board, and the board's own creation
|
|
132
|
+
// path is a write too. `"read"` here would put back exactly the hole #649 found.
|
|
133
|
+
const columns = await boardRepository.columnsOf(who, boardId, "write");
|
|
134
|
+
// `null` means the parent is not a board this actor may read — then there is nothing to
|
|
135
|
+
// file the task on, and the node stands on its own. That is the folder case, and it is
|
|
136
|
+
// silent on purpose: a `task` filed in a plain folder is unusual, not an error.
|
|
137
|
+
if (columns === null)
|
|
138
|
+
return;
|
|
139
|
+
const status = columns[0] ?? FIRST_DEFAULT_COLUMN;
|
|
140
|
+
await boardRepository.attach({
|
|
141
|
+
nodeId: taskId,
|
|
142
|
+
boardId,
|
|
143
|
+
status,
|
|
144
|
+
assigneeId: null,
|
|
145
|
+
labels: [],
|
|
146
|
+
startDate: null,
|
|
147
|
+
dueDate: null,
|
|
148
|
+
dependsOn: null,
|
|
149
|
+
position: await boardRepository.nextPosition(boardId, status),
|
|
150
|
+
occurredAt,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
117
153
|
indexing: createIndexQueue(env.INDEXING),
|
|
118
154
|
semantic,
|
|
119
155
|
externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
|
|
@@ -219,6 +255,8 @@ export default {
|
|
|
219
255
|
nodes,
|
|
220
256
|
flows,
|
|
221
257
|
tools,
|
|
258
|
+
audit: createAudit({ audit: auditRepository }),
|
|
259
|
+
boards: createBoards({ boards: boardRepository, nodes, now }),
|
|
222
260
|
bundle: createBundle({
|
|
223
261
|
repository: nodeRepository,
|
|
224
262
|
flows: flowRepository,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AuditRepository } from "../../audit/audit.types.js";
|
|
2
|
+
import type { D1Database } from "./db.types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The journal, cut down to the rows this actor may see, continued from a stable position.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **The visibility check is a JOIN against `allowed`, evaluated on every call.** `allowed` is
|
|
7
|
+
* the same tree walk `nodes` reads through (`db-grants.ts`), so an event about a node is visible
|
|
8
|
+
* exactly when the node is — no second rule that could drift from the first. It runs per row and
|
|
9
|
+
* per request; nothing here is cached, so a grant revoked a second ago is gone from the next
|
|
10
|
+
* answer.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ It filters the EXISTENCE of the row, not only its contents. That is deliberate and it is
|
|
13
|
+
* the stricter reading: that something is moving inside a customer's folder is itself
|
|
14
|
+
* information, so an actor who may not read the node learns nothing at all — not an id, not a
|
|
15
|
+
* count, not a timestamp.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **An event about a purged node is therefore unreachable, for everybody.** `allowed` walks
|
|
18
|
+
* `nodes`, and after `node.purge` that row is gone, so the join finds nothing. This is not an
|
|
19
|
+
* oversight and it cannot be fixed without giving up the check: at a deleted row there is no
|
|
20
|
+
* longer anything against which a claim to have been allowed to read it could be tested. Hiding
|
|
21
|
+
* is the safe direction. A consumer learns about creation, change, moves and archiving — never
|
|
22
|
+
* about final deletion (#620).
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **`ORDER BY` and the cursor comparison must name BOTH columns, in the same order.** Two
|
|
25
|
+
* events in the same millisecond are the normal case in a batch, not the exception; on
|
|
26
|
+
* `occurred_at` alone the second one is skipped in silence — no error, no log, just an event
|
|
27
|
+
* that never arrived. The tuple comparison below is written out by hand because SQLite has no
|
|
28
|
+
* row-value comparison here: "later timestamp, OR same timestamp and larger id".
|
|
29
|
+
*/
|
|
30
|
+
export declare const auditFeedQuery: string;
|
|
31
|
+
export declare function createAuditRepository(deps: {
|
|
32
|
+
db: D1Database;
|
|
33
|
+
now(): Date;
|
|
34
|
+
}): AuditRepository;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
2
|
+
function mapEvent(row) {
|
|
3
|
+
let metadata = {};
|
|
4
|
+
// The column is `NOT NULL DEFAULT '{}'`, but it is written by 11 call sites over
|
|
5
|
+
// `JSON.stringify` on whatever each one had at hand. A row that cannot be parsed, or that parses
|
|
6
|
+
// to something other than an object, must not take the whole page down with it: the reader would
|
|
7
|
+
// then be stuck at that cursor forever with no way past. An unreadable payload becomes `{}` and
|
|
8
|
+
// the event itself still arrives — the fact that something happened is the part a change feed
|
|
9
|
+
// cannot afford to lose.
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
12
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
13
|
+
metadata = parsed;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
metadata = {};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
id: row.id,
|
|
21
|
+
actorId: row.actor_id,
|
|
22
|
+
action: row.action,
|
|
23
|
+
resourceType: "node",
|
|
24
|
+
resourceId: row.resource_id,
|
|
25
|
+
metadata,
|
|
26
|
+
occurredAt: row.occurred_at,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The journal, cut down to the rows this actor may see, continued from a stable position.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ **The visibility check is a JOIN against `allowed`, evaluated on every call.** `allowed` is
|
|
33
|
+
* the same tree walk `nodes` reads through (`db-grants.ts`), so an event about a node is visible
|
|
34
|
+
* exactly when the node is — no second rule that could drift from the first. It runs per row and
|
|
35
|
+
* per request; nothing here is cached, so a grant revoked a second ago is gone from the next
|
|
36
|
+
* answer.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ It filters the EXISTENCE of the row, not only its contents. That is deliberate and it is
|
|
39
|
+
* the stricter reading: that something is moving inside a customer's folder is itself
|
|
40
|
+
* information, so an actor who may not read the node learns nothing at all — not an id, not a
|
|
41
|
+
* count, not a timestamp.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ **An event about a purged node is therefore unreachable, for everybody.** `allowed` walks
|
|
44
|
+
* `nodes`, and after `node.purge` that row is gone, so the join finds nothing. This is not an
|
|
45
|
+
* oversight and it cannot be fixed without giving up the check: at a deleted row there is no
|
|
46
|
+
* longer anything against which a claim to have been allowed to read it could be tested. Hiding
|
|
47
|
+
* is the safe direction. A consumer learns about creation, change, moves and archiving — never
|
|
48
|
+
* about final deletion (#620).
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ **`ORDER BY` and the cursor comparison must name BOTH columns, in the same order.** Two
|
|
51
|
+
* events in the same millisecond are the normal case in a batch, not the exception; on
|
|
52
|
+
* `occurred_at` alone the second one is skipped in silence — no error, no log, just an event
|
|
53
|
+
* that never arrived. The tuple comparison below is written out by hand because SQLite has no
|
|
54
|
+
* row-value comparison here: "later timestamp, OR same timestamp and larger id".
|
|
55
|
+
*/
|
|
56
|
+
export const auditFeedQuery = `${subtreeCte}
|
|
57
|
+
SELECT e.id, e.actor_id, e.action, e.resource_id, e.metadata_json, e.occurred_at
|
|
58
|
+
FROM audit_events e
|
|
59
|
+
JOIN allowed ON allowed.id = e.resource_id
|
|
60
|
+
WHERE e.resource_type = 'node'
|
|
61
|
+
AND (
|
|
62
|
+
? IS NULL
|
|
63
|
+
OR e.occurred_at > ?
|
|
64
|
+
OR (e.occurred_at = ? AND e.id > ?)
|
|
65
|
+
)
|
|
66
|
+
ORDER BY e.occurred_at ASC, e.id ASC
|
|
67
|
+
LIMIT ?`;
|
|
68
|
+
export function createAuditRepository(deps) {
|
|
69
|
+
const page = auditFeedQuery;
|
|
70
|
+
return {
|
|
71
|
+
async listNodeEvents(actor, query) {
|
|
72
|
+
const after = query.after ?? null;
|
|
73
|
+
const bindings = [
|
|
74
|
+
...subtreeBindings(actor, "read", deps.now().toISOString()),
|
|
75
|
+
after === null ? null : after.occurredAt,
|
|
76
|
+
after === null ? null : after.occurredAt,
|
|
77
|
+
after === null ? null : after.occurredAt,
|
|
78
|
+
after === null ? null : after.id,
|
|
79
|
+
query.limit,
|
|
80
|
+
];
|
|
81
|
+
const result = await deps.db
|
|
82
|
+
.prepare(page)
|
|
83
|
+
.bind(...bindings)
|
|
84
|
+
.all();
|
|
85
|
+
const rows = result.results ?? [];
|
|
86
|
+
return { events: rows.map(mapEvent) };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, BoardColumn } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
3
|
+
function parseList(raw, of) {
|
|
4
|
+
// The column is `NOT NULL DEFAULT '[]'`, but it is JSON written by this repository and read back
|
|
5
|
+
// by it — a value that cannot be parsed is a defect, not a caller's mistake. It must still not
|
|
6
|
+
// take the whole board down: one unreadable row would make the board unopenable, and there would
|
|
7
|
+
// be no way in to repair it.
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
void of;
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function mapTask(row, boardId) {
|
|
18
|
+
return {
|
|
19
|
+
id: row.id,
|
|
20
|
+
title: row.title,
|
|
21
|
+
/**
|
|
22
|
+
* ⚠️ **An archived card reports the ARCHIVE column, not the one it came from** — that is what
|
|
23
|
+
* makes the column real instead of a label. `board_tasks.status` keeps naming the working
|
|
24
|
+
* column underneath, untouched, and that is what a card pulled back out returns to.
|
|
25
|
+
*
|
|
26
|
+
* A card archived over `node_archive`, without anybody dragging it, therefore appears here too.
|
|
27
|
+
* That is the test which tells this design apart from a second stored status (D68).
|
|
28
|
+
*/
|
|
29
|
+
status: row.archived_at === null ? row.status : ARCHIVE_COLUMN_ID,
|
|
30
|
+
assigneeId: row.assignee_id,
|
|
31
|
+
labels: parseList(row.labels_json, "labels").filter((entry) => typeof entry === "string"),
|
|
32
|
+
startDate: row.start_date,
|
|
33
|
+
dueDate: row.due_date,
|
|
34
|
+
dependsOn: row.depends_on,
|
|
35
|
+
// ⚠️ The board itself is not a parent TASK. A card filed directly on the board has
|
|
36
|
+
// `parent_id = boardId`, and handing that back as `parentTaskId` would make every top-level
|
|
37
|
+
// card claim to be a subtask of the board — a tree one level too deep, in every view.
|
|
38
|
+
parentTaskId: row.parent_id === boardId ? null : row.parent_id,
|
|
39
|
+
position: row.position,
|
|
40
|
+
archivedAt: row.archived_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function createBoardRepository(deps) {
|
|
44
|
+
/**
|
|
45
|
+
* ⚠️ **The VERB is a parameter, and every caller has to name it.** It was hard-wired to `"read"`
|
|
46
|
+
* once, and the consequence was not a missing check but the WRONG one: `columnsOf` and `taskRow`
|
|
47
|
+
* are the only gates the board service passes before it writes, so a board shared read-only could
|
|
48
|
+
* have its columns replaced and its cards moved. The refusal was there, it simply asked whether
|
|
49
|
+
* the actor may LOOK (ADR-0004 §2).
|
|
50
|
+
*/
|
|
51
|
+
const walkBindings = (actor, verb) => subtreeBindings(actor, verb, deps.now().toISOString());
|
|
52
|
+
return {
|
|
53
|
+
/**
|
|
54
|
+
* The whole board in ONE answer: the node, its columns, and every card the actor may see.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **`JOIN allowed` on the TASK, not only on the board.** Reaching the board is not the same
|
|
57
|
+
* question as reaching a card in it — a grant can sit on a single task, and one on the board
|
|
58
|
+
* does not make its tasks readable by a different rule than the tree uses. Asking once at the
|
|
59
|
+
* top would be a second authorization model beside `allowed`, and the two would drift.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **Every filter is a WHERE, never a pass over the result.** A filter applied after reading
|
|
62
|
+
* has already carried the rows over the wire — which for `assigneeId` means titles of other
|
|
63
|
+
* people's cards in an answer that was supposed to exclude them.
|
|
64
|
+
*/
|
|
65
|
+
async read(actor, input) {
|
|
66
|
+
const board = await deps.db
|
|
67
|
+
.prepare(`${subtreeCte}
|
|
68
|
+
SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json,
|
|
69
|
+
COALESCE(b.archive_visible, 1) AS archive_visible
|
|
70
|
+
FROM nodes n
|
|
71
|
+
JOIN allowed ON allowed.id = n.id
|
|
72
|
+
LEFT JOIN boards b ON b.node_id = n.id
|
|
73
|
+
WHERE n.id = ? AND n.kind = 'board'`)
|
|
74
|
+
.bind(...walkBindings(actor, "read"), input.boardId)
|
|
75
|
+
.first();
|
|
76
|
+
if (board === null)
|
|
77
|
+
return null;
|
|
78
|
+
const conditions = ["t.board_id = ?"];
|
|
79
|
+
const bindings = [input.boardId];
|
|
80
|
+
if (input.status === ARCHIVE_COLUMN_ID) {
|
|
81
|
+
// ⚠️ The archive column is not a stored status — `board_tasks.status` keeps naming the
|
|
82
|
+
// working column underneath (D68). Matched against `t.status` the filter would answer
|
|
83
|
+
// "nothing here" on a board full of visibly archived cards, and the column would look
|
|
84
|
+
// broken rather than empty.
|
|
85
|
+
conditions.push("n.archived_at IS NOT NULL");
|
|
86
|
+
}
|
|
87
|
+
else if (input.status !== undefined) {
|
|
88
|
+
conditions.push("t.status = ?");
|
|
89
|
+
bindings.push(input.status);
|
|
90
|
+
}
|
|
91
|
+
if (input.assigneeId !== undefined) {
|
|
92
|
+
conditions.push("t.assignee_id = ?");
|
|
93
|
+
bindings.push(input.assigneeId);
|
|
94
|
+
}
|
|
95
|
+
if (input.dueBefore !== undefined) {
|
|
96
|
+
// ⚠️ `t.due_date IS NOT NULL` is not redundant beside `<`: in SQLite `NULL < 'x'` is NULL,
|
|
97
|
+
// which is not true, so the rows would be dropped anyway — but writing it out says that
|
|
98
|
+
// dropping them is the DECISION. A task with no due date is not "due before" anything.
|
|
99
|
+
conditions.push("t.due_date IS NOT NULL AND t.due_date < ?");
|
|
100
|
+
bindings.push(input.dueBefore);
|
|
101
|
+
}
|
|
102
|
+
if (input.dependsOn !== undefined) {
|
|
103
|
+
conditions.push("t.depends_on = ?");
|
|
104
|
+
bindings.push(input.dependsOn);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* ⚠️ **The archive column IS the archived cards** (D68, #674). While it is drawn they belong
|
|
108
|
+
* in the answer, or the column would always be empty and nobody could pull anything back out
|
|
109
|
+
* of it. `includeArchived` stays for the other direction: a caller — an agent, a report —
|
|
110
|
+
* that wants them even on a board where the column is hidden.
|
|
111
|
+
*/
|
|
112
|
+
const archiveVisible = board.archive_visible !== 0;
|
|
113
|
+
if (!input.includeArchived && !archiveVisible)
|
|
114
|
+
conditions.push("n.archived_at IS NULL");
|
|
115
|
+
const tasks = await deps.db
|
|
116
|
+
.prepare(`${subtreeCte}
|
|
117
|
+
SELECT n.id, n.title, n.parent_id, t.status, t.assignee_id, t.labels_json,
|
|
118
|
+
t.start_date, t.due_date, t.depends_on, t.position, n.archived_at
|
|
119
|
+
FROM board_tasks t
|
|
120
|
+
JOIN nodes n ON n.id = t.node_id
|
|
121
|
+
JOIN allowed ON allowed.id = n.id
|
|
122
|
+
WHERE ${conditions.join(" AND ")}
|
|
123
|
+
ORDER BY t.status, t.position, n.id`)
|
|
124
|
+
.bind(...walkBindings(actor, "read"), ...bindings)
|
|
125
|
+
.all();
|
|
126
|
+
// ⚠️ The CONFIGURED columns only. The archive column is appended by the service, after the
|
|
127
|
+
// fallback for an unconfigured board has been applied — appended here it would make every
|
|
128
|
+
// board look configured, and a board with no columns of its own would answer with the
|
|
129
|
+
// archive and nothing else.
|
|
130
|
+
const columns = parseList(board.statuses_json, "columns")
|
|
131
|
+
.map((entry) => BoardColumn.safeParse(entry))
|
|
132
|
+
.filter((parsed) => parsed.success)
|
|
133
|
+
.map((parsed) => parsed.data);
|
|
134
|
+
return {
|
|
135
|
+
boardId: board.id,
|
|
136
|
+
title: board.title,
|
|
137
|
+
archiveVisible,
|
|
138
|
+
columns,
|
|
139
|
+
tasks: (tasks.results ?? []).map((row) => mapTask(row, board.id)),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
async columnsOf(actor, boardId, verb) {
|
|
143
|
+
const row = await deps.db
|
|
144
|
+
.prepare(`${subtreeCte}
|
|
145
|
+
SELECT COALESCE(b.statuses_json, '[]') AS statuses_json
|
|
146
|
+
FROM nodes n
|
|
147
|
+
JOIN allowed ON allowed.id = n.id
|
|
148
|
+
LEFT JOIN boards b ON b.node_id = n.id
|
|
149
|
+
WHERE n.id = ? AND n.kind = 'board'`)
|
|
150
|
+
.bind(...walkBindings(actor, verb), boardId)
|
|
151
|
+
.first();
|
|
152
|
+
if (row === null)
|
|
153
|
+
return null;
|
|
154
|
+
return parseList(row.statuses_json, "columns")
|
|
155
|
+
.map((entry) => BoardColumn.safeParse(entry))
|
|
156
|
+
.filter((parsed) => parsed.success)
|
|
157
|
+
.map((parsed) => parsed.data.id);
|
|
158
|
+
},
|
|
159
|
+
async setColumns(boardId, columnsJson, occurredAt, archiveVisible) {
|
|
160
|
+
// ⚠️ `COALESCE(?, archive_visible)` rather than a second statement: an absent flag means
|
|
161
|
+
// "leave it", and writing a default here would un-hide an archive somebody put away every
|
|
162
|
+
// time the columns are reordered.
|
|
163
|
+
await deps.db
|
|
164
|
+
.prepare(`INSERT INTO boards (node_id, statuses_json, archive_visible, created_at, updated_at)
|
|
165
|
+
VALUES (?, ?, COALESCE(?, 1), ?, ?)
|
|
166
|
+
ON CONFLICT(node_id) DO UPDATE SET statuses_json = excluded.statuses_json,
|
|
167
|
+
archive_visible = COALESCE(?, boards.archive_visible),
|
|
168
|
+
updated_at = excluded.updated_at`)
|
|
169
|
+
.bind(boardId, columnsJson, archiveVisible === undefined ? null : Number(archiveVisible), occurredAt, occurredAt, archiveVisible === undefined ? null : Number(archiveVisible))
|
|
170
|
+
.run();
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* The row that makes a node a card.
|
|
174
|
+
*
|
|
175
|
+
* ⚠️ `INSERT … ON CONFLICT DO NOTHING`, because this runs on a path that may already have run:
|
|
176
|
+
* `nodes.create` under a board writes it, and so does `board_task_create`. Two rows are
|
|
177
|
+
* impossible (the primary key), and a second call must not overwrite a status somebody has
|
|
178
|
+
* since changed.
|
|
179
|
+
*/
|
|
180
|
+
async attach(write) {
|
|
181
|
+
await deps.db
|
|
182
|
+
.prepare(`INSERT INTO board_tasks (
|
|
183
|
+
node_id, board_id, status, assignee_id, labels_json,
|
|
184
|
+
start_date, due_date, depends_on, position, created_at, updated_at
|
|
185
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
186
|
+
ON CONFLICT(node_id) DO NOTHING`)
|
|
187
|
+
.bind(write.nodeId, write.boardId, write.status, write.assigneeId, JSON.stringify(write.labels), write.startDate, write.dueDate, write.dependsOn, write.position, write.occurredAt, write.occurredAt)
|
|
188
|
+
.run();
|
|
189
|
+
},
|
|
190
|
+
async taskRow(actor, taskId, verb) {
|
|
191
|
+
const row = await deps.db
|
|
192
|
+
.prepare(`${subtreeCte}
|
|
193
|
+
SELECT t.node_id, t.board_id, t.status, t.position, n.archived_at, n.updated_at
|
|
194
|
+
FROM board_tasks t
|
|
195
|
+
JOIN nodes n ON n.id = t.node_id
|
|
196
|
+
JOIN allowed ON allowed.id = n.id
|
|
197
|
+
WHERE t.node_id = ?`)
|
|
198
|
+
.bind(...walkBindings(actor, verb), taskId)
|
|
199
|
+
.first();
|
|
200
|
+
if (row === null)
|
|
201
|
+
return null;
|
|
202
|
+
return {
|
|
203
|
+
nodeId: row.node_id,
|
|
204
|
+
boardId: row.board_id,
|
|
205
|
+
status: row.status,
|
|
206
|
+
position: row.position,
|
|
207
|
+
archivedAt: row.archived_at,
|
|
208
|
+
updatedAt: row.updated_at,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* ⚠️ Only the named columns are written, and the statement is built from the fields that
|
|
213
|
+
* arrived. A full-row `UPDATE` would turn "move this card" into "write every field I happen to
|
|
214
|
+
* be holding" — and two people editing two different fields of one task would overwrite each
|
|
215
|
+
* other with values neither of them touched.
|
|
216
|
+
*/
|
|
217
|
+
async updateTask(taskId, fields, occurredAt) {
|
|
218
|
+
const sets = ["updated_at = ?"];
|
|
219
|
+
const bindings = [occurredAt];
|
|
220
|
+
for (const [column, value] of Object.entries(fields)) {
|
|
221
|
+
sets.push(`${column} = ?`);
|
|
222
|
+
bindings.push(value);
|
|
223
|
+
}
|
|
224
|
+
await deps.db
|
|
225
|
+
.prepare(`UPDATE board_tasks SET ${sets.join(", ")} WHERE node_id = ?`)
|
|
226
|
+
.bind(...bindings, taskId)
|
|
227
|
+
.run();
|
|
228
|
+
},
|
|
229
|
+
async nextPosition(boardId, status) {
|
|
230
|
+
const row = await deps.db
|
|
231
|
+
.prepare("SELECT MAX(position) AS top FROM board_tasks WHERE board_id = ? AND status = ?")
|
|
232
|
+
.bind(boardId, status)
|
|
233
|
+
.first();
|
|
234
|
+
// A whole number apart, so the first drop between two cards has room for a midpoint without
|
|
235
|
+
// needing fractions immediately.
|
|
236
|
+
return (row?.top ?? 0) + 1;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -112,19 +112,39 @@ export function createNodeRepository(deps) {
|
|
|
112
112
|
* ⚠️ In the bounded form `LIMIT` follows the `WHERE`, so the cut falls among the rows this actor
|
|
113
113
|
* may see and never before them, and `COUNT(*) OVER ()` counts those same rows alone (#30).
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* The kinds the FOLDER TREE does not show. A task belongs to its board and is reached there
|
|
117
|
+
* (D66, #376); the tree would otherwise flood with three hundred cards, which is the objection
|
|
118
|
+
* that sank the node-per-task idea the first time it was proposed.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ It is a cut in the STATEMENT, not a filter in the interface. Filtered later, the rows have
|
|
121
|
+
* already crossed the wire — titles of other people's tasks inside an answer that was not
|
|
122
|
+
* supposed to carry them.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ It says nothing about reachability. A task keeps its own address, its own grants and its own
|
|
125
|
+
* place in search and in the relation graph; only this one level query looks away. Hiding it
|
|
126
|
+
* everywhere would make a link to a task a dead end.
|
|
127
|
+
*/
|
|
128
|
+
const notInTree = (alias) => `${alias}.kind <> 'task'`;
|
|
115
129
|
const visibleChildren = (bounded) => `${visibleCte}
|
|
116
130
|
SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""},
|
|
117
131
|
-- Whether this row has children THIS actor may see (#59), asked of the same allowed set one
|
|
118
132
|
-- level down. A second rule written here would drift from the one above it, and the drift
|
|
119
133
|
-- would show as a chevron that opens onto nothing.
|
|
134
|
+
--
|
|
135
|
+
-- ⚠️ The same cut here as below, and it is the SAME rule read twice rather
|
|
136
|
+
-- than a second one: a board's children are all tasks, so with them hidden it has none this
|
|
137
|
+
-- actor can see, and the chevron falls away by itself (D66, #376). Leaving it out here would
|
|
138
|
+
-- draw a chevron that opens onto an empty level — exactly the drift the comment above warns
|
|
139
|
+
-- about, arriving through the door it was written for.
|
|
120
140
|
EXISTS (
|
|
121
141
|
SELECT 1 FROM nodes child
|
|
122
142
|
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
123
|
-
WHERE child.parent_id = n.id AND child.archived_at IS NULL
|
|
143
|
+
WHERE child.parent_id = n.id AND child.archived_at IS NULL AND ${notInTree("child")}
|
|
124
144
|
) AS has_children
|
|
125
145
|
FROM nodes n
|
|
126
146
|
JOIN allowed ON allowed.id = n.id
|
|
127
|
-
WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL)
|
|
147
|
+
WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL) AND ${notInTree("n")}
|
|
128
148
|
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
|
|
129
149
|
/**
|
|
130
150
|
* Everything archived that this actor may see, wherever it sits (#113).
|
|
@@ -156,6 +176,22 @@ export function createNodeRepository(deps) {
|
|
|
156
176
|
withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
|
|
157
177
|
};
|
|
158
178
|
},
|
|
179
|
+
/**
|
|
180
|
+
* ⚠️ Deliberately NOT `visibleChildren`: that builder cuts tasks out of every level, so it
|
|
181
|
+
* answers `0` for a task with any number of subtasks. This one asks the question the guard
|
|
182
|
+
* actually has — "is anything still hanging under this node".
|
|
183
|
+
*/
|
|
184
|
+
async countLiveChildren(nodeId) {
|
|
185
|
+
// ⚠️ No `allowed` join, deliberately: a grant can sit on a single node, so a child may be
|
|
186
|
+
// invisible to whoever archives its parent. Filtered by visibility this answers `0`, the
|
|
187
|
+
// refusal does not fire, and the child is later purged without being named. A count leaks a
|
|
188
|
+
// number and nothing else; the alternative leaks the child itself, permanently.
|
|
189
|
+
const result = await deps.db
|
|
190
|
+
.prepare("SELECT COUNT(*) AS total FROM nodes WHERE parent_id = ? AND archived_at IS NULL")
|
|
191
|
+
.bind(nodeId)
|
|
192
|
+
.first();
|
|
193
|
+
return result?.total ?? 0;
|
|
194
|
+
},
|
|
159
195
|
async listVisibleBounded(actor, input) {
|
|
160
196
|
const result = await deps.db
|
|
161
197
|
.prepare(visibleChildren(true))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
|
+
// The separator inside the encoded cursor. `\0` cannot occur in either half: an ISO timestamp
|
|
3
|
+
// is digits and punctuation, an Intel id is Crockford base32. Splitting on the FIRST occurrence
|
|
4
|
+
// would still be safe, but there is no occurrence to split on.
|
|
5
|
+
const SEPARATOR = "\0";
|
|
6
|
+
/**
|
|
7
|
+
* The pair, made into one string a reader cannot take apart by accident.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ This is not encryption and does not pretend to be. Anyone determined can decode base64url and
|
|
10
|
+
* read a timestamp. The point is the accident, not the attacker: a cursor that LOOKS like a
|
|
11
|
+
* timestamp invites a caller to send a timestamp, and a timestamp alone loses every second event
|
|
12
|
+
* written in the same millisecond. Making the shape unusable by hand is what keeps the pair whole.
|
|
13
|
+
*/
|
|
14
|
+
function encodeCursor(position) {
|
|
15
|
+
const raw = `${position.occurredAt}${SEPARATOR}${position.id}`;
|
|
16
|
+
return btoa(String.fromCharCode(...new TextEncoder().encode(raw)))
|
|
17
|
+
.replaceAll("+", "-")
|
|
18
|
+
.replaceAll("/", "_")
|
|
19
|
+
.replaceAll("=", "");
|
|
20
|
+
}
|
|
21
|
+
function decodeCursor(cursor) {
|
|
22
|
+
// A cursor comes back from a consumer that stored it, possibly weeks ago, possibly truncated by
|
|
23
|
+
// whatever stored it. Every failure below is the same answer: this is not a cursor this service
|
|
24
|
+
// handed out. Guessing a position from a damaged one would silently move the reader — forward
|
|
25
|
+
// past unread events, or backward into duplicates.
|
|
26
|
+
const refuse = () => new IntelError(400, "invalid_cursor", "The cursor is not one this service issued. Pass back the nextCursor from a previous response unchanged, or omit it to start from the beginning.");
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
const padded = cursor.replaceAll("-", "+").replaceAll("_", "/");
|
|
30
|
+
const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
31
|
+
raw = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw refuse();
|
|
35
|
+
}
|
|
36
|
+
const separator = raw.indexOf(SEPARATOR);
|
|
37
|
+
if (separator <= 0 || separator === raw.length - 1)
|
|
38
|
+
throw refuse();
|
|
39
|
+
const occurredAt = raw.slice(0, separator);
|
|
40
|
+
const id = raw.slice(separator + 1);
|
|
41
|
+
// ⚠️ Both halves are checked, not just the split. A cursor whose timestamp decoded to an empty
|
|
42
|
+
// string would compare as "before everything" and quietly replay the whole journal.
|
|
43
|
+
if (occurredAt.length === 0 || id.length === 0)
|
|
44
|
+
throw refuse();
|
|
45
|
+
return { occurredAt, id };
|
|
46
|
+
}
|
|
47
|
+
export function createAudit(deps) {
|
|
48
|
+
return {
|
|
49
|
+
async list(actor, input) {
|
|
50
|
+
// `resourceType` is validated by the contract enum before it reaches here; the value is
|
|
51
|
+
// named again rather than assumed, so adding `flow` to the enum later cannot silently route
|
|
52
|
+
// flow events through the node visibility walk.
|
|
53
|
+
if (input.resourceType !== "node") {
|
|
54
|
+
throw new IntelError(400, "unsupported_resource_type", "Only node events can be listed today. Flow and flow-run events need their own visibility check and are refused rather than omitted, so that an empty answer never has to mean two different things.");
|
|
55
|
+
}
|
|
56
|
+
const after = input.after === undefined ? null : decodeCursor(input.after);
|
|
57
|
+
// One more than asked for, so "is there another page" is answered by the same authorized
|
|
58
|
+
// query instead of a second one that could disagree with it.
|
|
59
|
+
const page = await deps.audit.listNodeEvents(actor, { after, limit: input.limit + 1 });
|
|
60
|
+
const hasMore = page.events.length > input.limit;
|
|
61
|
+
const events = hasMore ? page.events.slice(0, input.limit) : page.events;
|
|
62
|
+
const last = events.at(-1);
|
|
63
|
+
return {
|
|
64
|
+
events,
|
|
65
|
+
// ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
|
|
66
|
+
// from the probe would skip exactly one event per page — the classic off-by-one that shows
|
|
67
|
+
// up as a lead nobody contacted, not as an error.
|
|
68
|
+
nextCursor: hasMore && last !== undefined ? encodeCursor(last) : null,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuditEvent, AuditListRequest, AuditListResponse } from "@anchrd/intel-contract/audit";
|
|
2
|
+
import type { Actor } from "../nodes/nodes.types.js";
|
|
3
|
+
export interface AuditPosition {
|
|
4
|
+
occurredAt: string;
|
|
5
|
+
id: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AuditQuery {
|
|
8
|
+
after: AuditPosition | null;
|
|
9
|
+
limit: number;
|
|
10
|
+
}
|
|
11
|
+
export interface AuditPage {
|
|
12
|
+
events: AuditEvent[];
|
|
13
|
+
}
|
|
14
|
+
export interface AuditRepository {
|
|
15
|
+
listNodeEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
|
|
16
|
+
}
|
|
17
|
+
export interface AuditDeps {
|
|
18
|
+
audit: AuditRepository;
|
|
19
|
+
}
|
|
20
|
+
export interface AuditService {
|
|
21
|
+
list(actor: Actor, input: AuditListRequest): Promise<AuditListResponse>;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BoardDeps, BoardService } from "./boards.types.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
4
|
+
* nothing asks whoever made it to define columns before filing the first card.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
|
|
7
|
+
* shared data: two people on one board must read the same names, and the settings dialog edits one
|
|
8
|
+
* list. So the application writes them at creation, in the language of whoever created it (#675),
|
|
9
|
+
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
10
|
+
* configured — one made over MCP, or one whose seeding did not land.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_COLUMNS: readonly [{
|
|
13
|
+
readonly id: "backlog";
|
|
14
|
+
readonly title: "Backlog";
|
|
15
|
+
readonly terminal: false;
|
|
16
|
+
}, {
|
|
17
|
+
readonly id: "todo";
|
|
18
|
+
readonly title: "Offen";
|
|
19
|
+
readonly terminal: false;
|
|
20
|
+
}, {
|
|
21
|
+
readonly id: "doing";
|
|
22
|
+
readonly title: "Läuft";
|
|
23
|
+
readonly terminal: false;
|
|
24
|
+
}, {
|
|
25
|
+
readonly id: "done";
|
|
26
|
+
readonly title: "Fertig";
|
|
27
|
+
readonly terminal: true;
|
|
28
|
+
}];
|
|
29
|
+
export declare const FIRST_DEFAULT_COLUMN: "backlog";
|
|
30
|
+
export declare function createBoards(deps: BoardDeps): BoardService;
|