@anchrd/intel-api 0.27.0 → 0.28.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.
@@ -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,32 @@ 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
+ attachToBoard: async (who, taskId, boardId, occurredAt) => {
127
+ // ⚠️ `"write"` — the actor is filing a task on this board, and the board's own creation
128
+ // path is a write too. `"read"` here would put back exactly the hole #649 found.
129
+ const columns = await boardRepository.columnsOf(who, boardId, "write");
130
+ // `null` means the parent is not a board this actor may read — then there is nothing to
131
+ // file the task on, and the node stands on its own. That is the folder case, and it is
132
+ // silent on purpose: a `task` filed in a plain folder is unusual, not an error.
133
+ if (columns === null)
134
+ return;
135
+ const status = columns[0] ?? FIRST_DEFAULT_COLUMN;
136
+ await boardRepository.attach({
137
+ nodeId: taskId,
138
+ boardId,
139
+ status,
140
+ assigneeId: null,
141
+ labels: [],
142
+ startDate: null,
143
+ dueDate: null,
144
+ dependsOn: null,
145
+ position: await boardRepository.nextPosition(boardId, status),
146
+ occurredAt,
147
+ });
148
+ },
117
149
  indexing: createIndexQueue(env.INDEXING),
118
150
  semantic,
119
151
  externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
@@ -219,6 +251,8 @@ export default {
219
251
  nodes,
220
252
  flows,
221
253
  tools,
254
+ audit: createAudit({ audit: auditRepository }),
255
+ boards: createBoards({ boards: boardRepository, nodes, now }),
222
256
  bundle: createBundle({
223
257
  repository: nodeRepository,
224
258
  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,6 @@
1
+ import type { BoardRepository } from "../../boards/boards.types.js";
2
+ import type { D1Database } from "./db.types.js";
3
+ export declare function createBoardRepository(deps: {
4
+ db: D1Database;
5
+ now(): Date;
6
+ }): BoardRepository;
@@ -0,0 +1,201 @@
1
+ import { 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) {
18
+ return {
19
+ id: row.id,
20
+ title: row.title,
21
+ status: row.status,
22
+ assigneeId: row.assignee_id,
23
+ labels: parseList(row.labels_json, "labels").filter((entry) => typeof entry === "string"),
24
+ startDate: row.start_date,
25
+ dueDate: row.due_date,
26
+ dependsOn: row.depends_on,
27
+ position: row.position,
28
+ archivedAt: row.archived_at,
29
+ };
30
+ }
31
+ export function createBoardRepository(deps) {
32
+ /**
33
+ * ⚠️ **The VERB is a parameter, and every caller has to name it.** It was hard-wired to `"read"`
34
+ * once, and the consequence was not a missing check but the WRONG one: `columnsOf` and `taskRow`
35
+ * are the only gates the board service passes before it writes, so a board shared read-only could
36
+ * have its columns replaced and its cards moved. The refusal was there, it simply asked whether
37
+ * the actor may LOOK (ADR-0004 §2).
38
+ */
39
+ const walkBindings = (actor, verb) => subtreeBindings(actor, verb, deps.now().toISOString());
40
+ return {
41
+ /**
42
+ * The whole board in ONE answer: the node, its columns, and every card the actor may see.
43
+ *
44
+ * ⚠️ **`JOIN allowed` on the TASK, not only on the board.** Reaching the board is not the same
45
+ * question as reaching a card in it — a grant can sit on a single task, and one on the board
46
+ * does not make its tasks readable by a different rule than the tree uses. Asking once at the
47
+ * top would be a second authorization model beside `allowed`, and the two would drift.
48
+ *
49
+ * ⚠️ **Every filter is a WHERE, never a pass over the result.** A filter applied after reading
50
+ * has already carried the rows over the wire — which for `assigneeId` means titles of other
51
+ * people's cards in an answer that was supposed to exclude them.
52
+ */
53
+ async read(actor, input) {
54
+ const board = await deps.db
55
+ .prepare(`${subtreeCte}
56
+ SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json
57
+ FROM nodes n
58
+ JOIN allowed ON allowed.id = n.id
59
+ LEFT JOIN boards b ON b.node_id = n.id
60
+ WHERE n.id = ? AND n.kind = 'board'`)
61
+ .bind(...walkBindings(actor, "read"), input.boardId)
62
+ .first();
63
+ if (board === null)
64
+ return null;
65
+ const conditions = ["t.board_id = ?"];
66
+ const bindings = [input.boardId];
67
+ if (input.status !== undefined) {
68
+ conditions.push("t.status = ?");
69
+ bindings.push(input.status);
70
+ }
71
+ if (input.assigneeId !== undefined) {
72
+ conditions.push("t.assignee_id = ?");
73
+ bindings.push(input.assigneeId);
74
+ }
75
+ if (input.dueBefore !== undefined) {
76
+ // ⚠️ `t.due_date IS NOT NULL` is not redundant beside `<`: in SQLite `NULL < 'x'` is NULL,
77
+ // which is not true, so the rows would be dropped anyway — but writing it out says that
78
+ // dropping them is the DECISION. A task with no due date is not "due before" anything.
79
+ conditions.push("t.due_date IS NOT NULL AND t.due_date < ?");
80
+ bindings.push(input.dueBefore);
81
+ }
82
+ if (input.dependsOn !== undefined) {
83
+ conditions.push("t.depends_on = ?");
84
+ bindings.push(input.dependsOn);
85
+ }
86
+ if (!input.includeArchived)
87
+ conditions.push("n.archived_at IS NULL");
88
+ const tasks = await deps.db
89
+ .prepare(`${subtreeCte}
90
+ SELECT n.id, n.title, t.status, t.assignee_id, t.labels_json,
91
+ t.start_date, t.due_date, t.depends_on, t.position, n.archived_at
92
+ FROM board_tasks t
93
+ JOIN nodes n ON n.id = t.node_id
94
+ JOIN allowed ON allowed.id = n.id
95
+ WHERE ${conditions.join(" AND ")}
96
+ ORDER BY t.status, t.position, n.id`)
97
+ .bind(...walkBindings(actor, "read"), ...bindings)
98
+ .all();
99
+ const columns = parseList(board.statuses_json, "columns")
100
+ .map((entry) => BoardColumn.safeParse(entry))
101
+ .filter((parsed) => parsed.success)
102
+ .map((parsed) => parsed.data);
103
+ return {
104
+ boardId: board.id,
105
+ title: board.title,
106
+ columns,
107
+ tasks: (tasks.results ?? []).map(mapTask),
108
+ };
109
+ },
110
+ async columnsOf(actor, boardId, verb) {
111
+ const row = await deps.db
112
+ .prepare(`${subtreeCte}
113
+ SELECT COALESCE(b.statuses_json, '[]') AS statuses_json
114
+ FROM nodes n
115
+ JOIN allowed ON allowed.id = n.id
116
+ LEFT JOIN boards b ON b.node_id = n.id
117
+ WHERE n.id = ? AND n.kind = 'board'`)
118
+ .bind(...walkBindings(actor, verb), boardId)
119
+ .first();
120
+ if (row === null)
121
+ return null;
122
+ return parseList(row.statuses_json, "columns")
123
+ .map((entry) => BoardColumn.safeParse(entry))
124
+ .filter((parsed) => parsed.success)
125
+ .map((parsed) => parsed.data.id);
126
+ },
127
+ async setColumns(boardId, columnsJson, occurredAt) {
128
+ await deps.db
129
+ .prepare(`INSERT INTO boards (node_id, statuses_json, created_at, updated_at)
130
+ VALUES (?, ?, ?, ?)
131
+ ON CONFLICT(node_id) DO UPDATE SET statuses_json = excluded.statuses_json,
132
+ updated_at = excluded.updated_at`)
133
+ .bind(boardId, columnsJson, occurredAt, occurredAt)
134
+ .run();
135
+ },
136
+ /**
137
+ * The row that makes a node a card.
138
+ *
139
+ * ⚠️ `INSERT … ON CONFLICT DO NOTHING`, because this runs on a path that may already have run:
140
+ * `nodes.create` under a board writes it, and so does `board_task_create`. Two rows are
141
+ * impossible (the primary key), and a second call must not overwrite a status somebody has
142
+ * since changed.
143
+ */
144
+ async attach(write) {
145
+ await deps.db
146
+ .prepare(`INSERT INTO board_tasks (
147
+ node_id, board_id, status, assignee_id, labels_json,
148
+ start_date, due_date, depends_on, position, created_at, updated_at
149
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
150
+ ON CONFLICT(node_id) DO NOTHING`)
151
+ .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)
152
+ .run();
153
+ },
154
+ async taskRow(actor, taskId, verb) {
155
+ const row = await deps.db
156
+ .prepare(`${subtreeCte}
157
+ SELECT t.node_id, t.board_id, t.status, t.position
158
+ FROM board_tasks t
159
+ JOIN nodes n ON n.id = t.node_id
160
+ JOIN allowed ON allowed.id = n.id
161
+ WHERE t.node_id = ?`)
162
+ .bind(...walkBindings(actor, verb), taskId)
163
+ .first();
164
+ if (row === null)
165
+ return null;
166
+ return {
167
+ nodeId: row.node_id,
168
+ boardId: row.board_id,
169
+ status: row.status,
170
+ position: row.position,
171
+ };
172
+ },
173
+ /**
174
+ * ⚠️ Only the named columns are written, and the statement is built from the fields that
175
+ * arrived. A full-row `UPDATE` would turn "move this card" into "write every field I happen to
176
+ * be holding" — and two people editing two different fields of one task would overwrite each
177
+ * other with values neither of them touched.
178
+ */
179
+ async updateTask(taskId, fields, occurredAt) {
180
+ const sets = ["updated_at = ?"];
181
+ const bindings = [occurredAt];
182
+ for (const [column, value] of Object.entries(fields)) {
183
+ sets.push(`${column} = ?`);
184
+ bindings.push(value);
185
+ }
186
+ await deps.db
187
+ .prepare(`UPDATE board_tasks SET ${sets.join(", ")} WHERE node_id = ?`)
188
+ .bind(...bindings, taskId)
189
+ .run();
190
+ },
191
+ async nextPosition(boardId, status) {
192
+ const row = await deps.db
193
+ .prepare("SELECT MAX(position) AS top FROM board_tasks WHERE board_id = ? AND status = ?")
194
+ .bind(boardId, status)
195
+ .first();
196
+ // A whole number apart, so the first drop between two cards has room for a midpoint without
197
+ // needing fractions immediately.
198
+ return (row?.top ?? 0) + 1;
199
+ },
200
+ };
201
+ }
@@ -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).
@@ -0,0 +1,2 @@
1
+ import type { AuditDeps, AuditService } from "./audit.types.js";
2
+ export declare function createAudit(deps: AuditDeps): AuditService;
@@ -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,16 @@
1
+ import type { BoardDeps, BoardService } from "./boards.types.js";
2
+ export declare const DEFAULT_COLUMNS: readonly [{
3
+ readonly id: "todo";
4
+ readonly title: "Offen";
5
+ readonly terminal: false;
6
+ }, {
7
+ readonly id: "doing";
8
+ readonly title: "Läuft";
9
+ readonly terminal: false;
10
+ }, {
11
+ readonly id: "done";
12
+ readonly title: "Fertig";
13
+ readonly terminal: true;
14
+ }];
15
+ export declare const FIRST_DEFAULT_COLUMN: "todo";
16
+ export declare function createBoards(deps: BoardDeps): BoardService;