@anchrd/intel-api 0.35.0 → 0.36.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/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/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
|
},
|
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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.28.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|