@anchrd/intel-api 0.31.0 → 0.32.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.
@@ -9,6 +9,7 @@ import { createFlows } from "../../flows/flows.js";
9
9
  import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
10
10
  import { createIntel } from "../../intel/intel.js";
11
11
  import { createNodes } from "../../nodes/nodes.js";
12
+ import { bearer } from "../../shared/gate-authorization/gate-authorization.js";
12
13
  import { IntelError } from "../../shared/intel-error/intel-error.js";
13
14
  import { sha256Hex } from "../../shared/sha256/sha256.js";
14
15
  import { createTools } from "../../tools/tools.js";
@@ -256,7 +257,49 @@ export default {
256
257
  flows,
257
258
  tools,
258
259
  audit: createAudit({ audit: auditRepository }),
259
- boards: createBoards({ boards: boardRepository, nodes, now }),
260
+ boards: createBoards({
261
+ boards: boardRepository,
262
+ nodes,
263
+ now,
264
+ /**
265
+ * The seam to gate's directory (#700, D70).
266
+ *
267
+ * ⚠️ BUILT HERE AND NOWHERE ELSE, because this is the only place that legitimately holds
268
+ * the caller's bearer. `Actor` carries the resolved identity, never the credential that
269
+ * produced it, and the board service must stay that way: it narrows what gate offers, it
270
+ * does not get to speak for anybody.
271
+ *
272
+ * ⚠️ The token is resolved LAZILY and at most once. Two doors lead in and they answer
273
+ * differently — `/mcp` and the API carry an `Authorization` header, the browser carries a
274
+ * cookie the session has to be asked about — and doing that work eagerly would cost a
275
+ * session read on every request that never opens a picker.
276
+ *
277
+ * ⚠️ No token means an EMPTY answer, never a throw. A picker with no names shows nothing;
278
+ * a board that refuses to load because gate was slow is the worse outcome, and it would
279
+ * take the whole screen with it.
280
+ */
281
+ directory: (() => {
282
+ let pending = null;
283
+ const token = () => {
284
+ pending ??= (async () => bearer(request.headers) ?? (await auth.resolve(request.headers))?.bearer ?? null)();
285
+ return pending;
286
+ };
287
+ return {
288
+ async search(query) {
289
+ const value = await token();
290
+ if (!value)
291
+ return [];
292
+ return await gate.directory.search(value, { q: query });
293
+ },
294
+ async resolve(ids) {
295
+ const value = await token();
296
+ if (!value)
297
+ return [];
298
+ return await gate.directory.resolve(value, { ids });
299
+ },
300
+ };
301
+ })(),
302
+ }),
260
303
  bundle: createBundle({
261
304
  repository: nodeRepository,
262
305
  flows: flowRepository,
@@ -1,5 +1,5 @@
1
1
  import { ARCHIVE_COLUMN_ID, BoardColumn } from "@anchrd/intel-contract/board";
2
- import { subtreeBindings, subtreeCte } from "./db-grants.js";
2
+ import { grantInForce, subtreeBindings, subtreeCte } from "./db-grants.js";
3
3
  function parseList(raw, of) {
4
4
  // The column is `NOT NULL DEFAULT '[]'`, but it is JSON written by this repository and read back
5
5
  // by it — a value that cannot be parsed is a defect, not a caller's mistake. It must still not
@@ -235,5 +235,63 @@ export function createBoardRepository(deps) {
235
235
  // needing fractions immediately.
236
236
  return (row?.top ?? 0) + 1;
237
237
  },
238
+ /**
239
+ * Who reaches this board once inheritance is resolved (#700, D70).
240
+ *
241
+ * ⚠️ ONE statement, hence one D1 snapshot, for the reason `listEffectiveAccess` gives at the
242
+ * node repository: two parallel SELECTs are not atomic, and a grant mutation between their
243
+ * snapshots produces a set of people that never existed as a whole.
244
+ *
245
+ * ⚠️ It answers the THREE things the caller needs, not a list of grants. A service that
246
+ * received grant rows here would be one careless `return` away from handing a board's
247
+ * membership to anyone who can read it; what leaves this method cannot be turned back into who
248
+ * granted what to whom.
249
+ *
250
+ * ⚠️ `verb = 'read'`, and that is not a detail. The verbs here are granted INDEPENDENTLY
251
+ * (ADR-0004 §2): `write` does not follow from `read` and `read` does not follow from `write`.
252
+ * Counting any verb would offer somebody who holds only `write` or `share` as an assignee for a
253
+ * board they cannot open — the exact opposite of what D70 decided, arrived at by asking a
254
+ * slightly different question than `columnsOrRefuse` asks two lines earlier.
255
+ *
256
+ * ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` has to end the recursion rather than the
257
+ * database, the same reason every other walk of this tree gives.
258
+ */
259
+ async effectiveAccess(boardId) {
260
+ const row = await deps.db
261
+ .prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
262
+ SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
263
+ UNION
264
+ SELECT parent.id, parent.parent_id, parent.owner_id
265
+ FROM nodes parent
266
+ JOIN ancestors child ON child.parent_id = parent.id
267
+ )
268
+ SELECT
269
+ (SELECT json_group_array(owner_id)
270
+ FROM (SELECT DISTINCT owner_id FROM ancestors WHERE owner_id IS NOT NULL))
271
+ AS owner_ids_json,
272
+ (SELECT json_group_array(principal_id)
273
+ FROM (
274
+ SELECT DISTINCT grant_row.principal_id
275
+ FROM node_grants grant_row
276
+ JOIN ancestors ON ancestors.id = grant_row.node_id
277
+ WHERE grant_row.principal_type = 'user'
278
+ AND grant_row.principal_id IS NOT NULL
279
+ AND grant_row.verb = 'read'
280
+ AND ${grantInForce}
281
+ )) AS principal_ids_json,
282
+ (SELECT COUNT(*)
283
+ FROM node_grants grant_row
284
+ JOIN ancestors ON ancestors.id = grant_row.node_id
285
+ WHERE grant_row.principal_type = 'organization'
286
+ AND grant_row.verb = 'read'
287
+ AND ${grantInForce}) AS organization_grants`)
288
+ .bind(boardId, deps.now().toISOString(), deps.now().toISOString())
289
+ .first();
290
+ return {
291
+ ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
292
+ principalIds: JSON.parse(row?.principal_ids_json ?? "[]"),
293
+ organizationWide: (row?.organization_grants ?? 0) > 0,
294
+ };
295
+ },
238
296
  };
239
297
  }
@@ -20,6 +20,10 @@ export const DEFAULT_COLUMNS = [
20
20
  // than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
21
21
  // copy of it would drift exactly where it is the only thing deciding.
22
22
  export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
23
+ // Below this a query answers empty rather than with everybody. A single letter is not a search, it
24
+ // is a listing under a different name — the same floor gate holds at its own door, stated here so
25
+ // this surface keeps its own promise (#700).
26
+ const MINIMUM_QUERY = 2;
23
27
  export function createBoards(deps) {
24
28
  /**
25
29
  * ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
@@ -284,5 +288,60 @@ export function createBoards(deps) {
284
288
  }
285
289
  return await viewOrRefuse(actor, { boardId: row.boardId, includeArchived: false });
286
290
  },
291
+ /**
292
+ * Who this card may be given to (D70, #700).
293
+ *
294
+ * ⚠️ THE ANSWER IS NARROWER THAN GATE'S, NEVER WIDER. gate is asked first and its hits are then
295
+ * dropped down to the people who can actually open this board. That order is what makes the
296
+ * door safe to offer to anyone who can read the board: whatever survives the filter, gate would
297
+ * have handed the same person anyway at its own `POST /directory/search`.
298
+ *
299
+ * The reverse order would have been the leak: asking the board first and then naming the
300
+ * principals would hand out a membership list for a board to anyone who can see it.
301
+ */
302
+ async searchAssignees(actor, input) {
303
+ // `read`, not `share`: offering an assignee is something everyone who can open the board
304
+ // does, while managing grants is not. The 404 from here is the same one every other board
305
+ // read gives, so a board one may not see stays indistinguishable from one that is not there.
306
+ await columnsOrRefuse(actor, input.boardId, "read");
307
+ if (!deps.directory)
308
+ return { items: [] };
309
+ /**
310
+ * ⚠️ ENFORCED HERE, not only described. The contract's `query` says a single character
311
+ * answers empty, and until this line that sentence was true only because gate happens to
312
+ * hold the same floor — a promise this repository made and a different service kept.
313
+ *
314
+ * It is also the cheaper answer: a letter that can only ever produce a listing does not
315
+ * need to travel to gate first to be refused there.
316
+ */
317
+ if (input.query.trim().length < MINIMUM_QUERY)
318
+ return { items: [] };
319
+ const hits = await deps.directory.search(input.query);
320
+ if (hits.length === 0)
321
+ return { items: [] };
322
+ const access = await deps.boards.effectiveAccess(input.boardId);
323
+ // A grant to the organization means everybody reaches the board, so there is nothing left to
324
+ // narrow — and narrowing anyway would produce an empty picker on exactly the boards that are
325
+ // shared with everyone, which is most of them.
326
+ if (access.organizationWide)
327
+ return { items: hits };
328
+ const reaches = new Set([...access.ownerIds, ...access.principalIds]);
329
+ return { items: hits.filter((hit) => reaches.has(hit.id)) };
330
+ },
331
+ /**
332
+ * What the people already recorded on cards are called (#258, #700).
333
+ *
334
+ * ⚠️ NOT narrowed by the board, and that is the difference from the search above. Someone whose
335
+ * grant was withdrawn, or whose account was switched off, is still the person this card was
336
+ * given to. Filtering them out would leave the card reading as unassigned, which is a worse
337
+ * answer than the truth — and it would not protect anything, because the caller already holds
338
+ * the id and got it from a card they may read.
339
+ */
340
+ async resolveAssignees(actor, input) {
341
+ void actor;
342
+ if (!deps.directory)
343
+ return { items: [] };
344
+ return { items: await deps.directory.resolve(input.ids) };
345
+ },
287
346
  };
288
347
  }
@@ -1,4 +1,4 @@
1
- import type { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, BoardView } from "@anchrd/intel-contract/board";
1
+ import type { BoardAssignee, BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, BoardView } from "@anchrd/intel-contract/board";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
3
  import type { Actor } from "../nodes/nodes.types.js";
4
4
  export type { BoardView };
@@ -42,6 +42,33 @@ export interface BoardRepository {
42
42
  taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
43
43
  updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
44
44
  nextPosition(boardId: string, status: string): Promise<number>;
45
+ /**
46
+ * Who reaches this node once inheritance is resolved: every grant along the folders above plus
47
+ * the owners. The same answer `listEffectiveAccess` gives, reached through the SAME repository
48
+ * method, so the two can never disagree about who has access.
49
+ *
50
+ * ⚠️ Read here WITHOUT the `share` permission the application-level `listEffectiveAccess`
51
+ * demands, and that is deliberate rather than an oversight. The two callers ask for different
52
+ * reasons: managing sharing is an owner's job, while naming the people a card may be given to is
53
+ * something anyone who can open the board has to be able to do. The answer never leaves this
54
+ * service as a list of grants; it is only ever used to NARROW what gate already offers.
55
+ */
56
+ effectiveAccess(boardId: string): Promise<{
57
+ ownerIds: string[];
58
+ principalIds: string[];
59
+ organizationWide: boolean;
60
+ }>;
61
+ }
62
+ /**
63
+ * The one seam to gate's directory, built PER REQUEST around the caller's bearer.
64
+ *
65
+ * ⚠️ The bearer never reaches this service, and it must not: `Actor` is the resolved identity, not
66
+ * the credential that produced it. The edge already holds the token because it just authorized with
67
+ * it, so it closes over it here and hands in something that answers questions instead.
68
+ */
69
+ export interface BoardDirectoryPort {
70
+ search(query: string): Promise<BoardAssignee[]>;
71
+ resolve(ids: string[]): Promise<BoardAssignee[]>;
45
72
  }
46
73
  export interface BoardNodePort {
47
74
  create(actor: Actor, input: {
@@ -67,10 +94,17 @@ export interface BoardDeps {
67
94
  boards: BoardRepository;
68
95
  nodes: BoardNodePort;
69
96
  now(): Date;
97
+ directory?: BoardDirectoryPort;
70
98
  }
71
99
  export interface BoardService {
72
100
  get(actor: Actor, input: BoardGetInput): Promise<BoardView>;
73
101
  update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
74
102
  createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
75
103
  updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
104
+ searchAssignees(actor: Actor, input: BoardAssigneeSearchInput): Promise<{
105
+ items: BoardAssignee[];
106
+ }>;
107
+ resolveAssignees(actor: Actor, input: BoardAssigneeResolveInput): Promise<{
108
+ items: BoardAssignee[];
109
+ }>;
76
110
  }
package/dist/http/http.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AuditListRequest } from "@anchrd/intel-contract/audit";
2
- import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
2
+ import { BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
3
3
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
4
4
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
5
5
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -245,6 +245,34 @@ export function createHttp(deps) {
245
245
  });
246
246
  return context.json(await deps.boards.get(asActor(auth), input));
247
247
  });
248
+ /**
249
+ * Who this card may be given to (#700, D70).
250
+ *
251
+ * ⚠️ `nodes/read`, not a capability of its own and certainly not `nodes/write`. Offering an
252
+ * assignee is something everyone who can open the board does; a separate permission would be one
253
+ * more thing to grant before a picker works, and nothing would be safer for it — the answer is
254
+ * already narrower than what gate hands any signed-in person at its own door.
255
+ */
256
+ app.get("/boards/:boardId/assignees", async (context) => {
257
+ const auth = requireCapability(context, "nodes", "read");
258
+ const input = BoardAssigneeSearchInput.parse({
259
+ boardId: context.req.param("boardId"),
260
+ query: context.req.query("q") ?? "",
261
+ });
262
+ return context.json(await deps.boards.searchAssignees(asActor(auth), input));
263
+ });
264
+ /**
265
+ * What the people already on cards are called (#258, #700).
266
+ *
267
+ * ⚠️ A POST although it reads, and for the same reason gate's own door is one: the ids travel in
268
+ * the body. A list of a hundred identifiers in a query string lands in proxy logs and in browser
269
+ * history, and it would hit the length limit long before the hundredth.
270
+ */
271
+ app.post("/boards/assignees/resolve", async (context) => {
272
+ const auth = requireCapability(context, "nodes", "read");
273
+ const input = BoardAssigneeResolveInput.parse(await context.req.json().catch(() => null));
274
+ return context.json(await deps.boards.resolveAssignees(asActor(auth), input));
275
+ });
248
276
  app.patch("/boards/:boardId", async (context) => {
249
277
  const auth = requireCapability(context, "nodes", "write");
250
278
  const input = BoardUpdateInput.parse(await context.req.json().catch(() => null));
package/dist/mcp/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
2
  import { AuditListRequest } from "@anchrd/intel-contract/audit";
3
- import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
3
+ import { BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
4
4
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
5
5
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
6
6
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -180,6 +180,41 @@ export async function handleMcp(request, deps) {
180
180
  openWorldHint: false,
181
181
  },
182
182
  }, async (input) => text(await deps.boards.get(actor, input)));
183
+ // ⚠️ Two tools rather than one, because they answer two questions that differ in exactly one
184
+ // way: `board_assignee_list` offers whom a card MAY be given to and is narrowed to this board
185
+ // (D70); `board_assignee_resolve` names people who are ALREADY on cards and is not narrowed,
186
+ // because somebody whose access was withdrawn is still who the card belongs to.
187
+ //
188
+ // ⚠️ `list`, NOT `search`, although a query narrows it. `search` is the ONE bare verb at the
189
+ // root of this surface (Jack's decision 2026-08-09, `packages/api/CLAUDE.md`), and a second one
190
+ // behind a domain would make that exception look like a pattern. The verb for "reads many" is
191
+ // `list`, and a filter does not change which verb a call is.
192
+ server.registerTool("board_assignee_list", {
193
+ title: "Who a card on this board can be given to",
194
+ description: "Find the people you may set as assignee on a card of this board, by typing part of a name or an address. Only people who can actually open THIS board are offered, so the same query against two boards can give two different answers. Under two characters the answer is empty rather than everybody. Use board_assignee_resolve to name somebody already on a card.",
195
+ inputSchema: BoardAssigneeSearchInput,
196
+ annotations: {
197
+ title: "Who a card on this board can be given to",
198
+ readOnlyHint: true,
199
+ destructiveHint: false,
200
+ idempotentHint: true,
201
+ // ⚠️ The answer comes from gate, not from this database, and it changes when an account
202
+ // or a grant does. `false` would promise a stability nothing here controls.
203
+ openWorldHint: true,
204
+ },
205
+ }, async (input) => text(await deps.boards.searchAssignees(actor, input)));
206
+ server.registerTool("board_assignee_resolve", {
207
+ title: "What the people on these cards are called",
208
+ description: "Turn assignee ids into names, so a card can say who it belongs to instead of showing an identifier. Ids that cannot be named are absent from the answer rather than reported as missing. Not narrowed to a board: somebody whose access was withdrawn is still who the card belongs to.",
209
+ inputSchema: BoardAssigneeResolveInput,
210
+ annotations: {
211
+ title: "What the people on these cards are called",
212
+ readOnlyHint: true,
213
+ destructiveHint: false,
214
+ idempotentHint: true,
215
+ openWorldHint: true,
216
+ },
217
+ }, async (input) => text(await deps.boards.resolveAssignees(actor, input)));
183
218
  server.registerTool("board_task_create", {
184
219
  title: "Add a card to a board",
185
220
  description: "File a new card on a board. Omit the status to put it in the first column. Name a parent task to make it a subtask — it lands on the same board, and moving an existing card under another one is `node_update` with a new parent. The card is a node like any other: its own address, its own permissions, its own history.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -42,8 +42,8 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "dependencies": {
45
- "@anchrd/gate-sdk": "^0.24.0",
46
- "@anchrd/intel-contract": "^0.24.0",
45
+ "@anchrd/gate-sdk": "^0.25.0",
46
+ "@anchrd/intel-contract": "^0.25.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",