@anchrd/intel-api 0.24.0 → 0.26.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/mcp/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
2
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
3
3
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
4
- import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
- import { ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
4
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
+ import { ListEffectiveAccessInput, ListFlowEffectiveAccessInput, ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
6
6
  import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
7
7
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
8
8
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -13,6 +13,44 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
13
13
  function text(value) {
14
14
  return { content: [{ type: "text", text: JSON.stringify(value) }], isError: false };
15
15
  }
16
+ /**
17
+ * ⚠️ The one thing a tool error loses on its way out, and the whole reason this `catch` exists
18
+ * (#597).
19
+ *
20
+ * A thrown `IntelError` is turned into a tool error by the SDK's `createToolError`, which keeps
21
+ * `error instanceof Error ? error.message : String(error)` and nothing else — so `callers`, the
22
+ * structured half of a refusal that NAMES flows (#448, #593), never reaches a model over MCP.
23
+ *
24
+ * It arrives here as a SECOND content block. The first one is the sentence, byte for byte what the
25
+ * SDK would have sent, so a reader that only takes text loses nothing: the machine-readable half
26
+ * stands beside the prose rather than instead of it.
27
+ *
28
+ * ⚠️ It wraps the handlers whose service can refuse WITH data — `node_grant_revoke` and
29
+ * `flow_purge` today — and deliberately NOT all sixty. For every other refusal there is nothing to
30
+ * carry, and turning each of their errors into JSON would change every tool for the sake of two.
31
+ * What keeps that from becoming a place to forget is where the rule is written down: at the field
32
+ * itself (`IntelError.callers`), in `packages/api/CLAUDE.md`, and as two tests in `mcp.e2e.ts` —
33
+ * whoever attaches `callers` to a new refusal wraps its handler here.
34
+ */
35
+ async function refusing(run) {
36
+ try {
37
+ return text(await run());
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof IntelError) || !error.callers)
41
+ throw error;
42
+ return {
43
+ content: [
44
+ { type: "text", text: error.message },
45
+ {
46
+ type: "text",
47
+ text: JSON.stringify({ code: error.code, callers: error.callers }),
48
+ },
49
+ ],
50
+ isError: true,
51
+ };
52
+ }
53
+ }
16
54
  /**
17
55
  * ⚠️ One name per tool, and the second one is gone for good (#149).
18
56
  *
@@ -464,6 +502,34 @@ export async function handleMcp(request, deps) {
464
502
  openWorldHint: false,
465
503
  },
466
504
  }, async (input) => text(await deps.nodes.archive(actor, input)));
505
+ /**
506
+ * ⚠️ The look before the irreversible step, and it is on this surface because the step itself
507
+ * is (#586). `node_purge` has stood here since #457 for the reason #436 states — a way only the
508
+ * interface has is a way an agent goes around — but until now the COUNT did not, so an agent
509
+ * could delete for good without being able to see first what went with it. A person in the
510
+ * interface is shown both numbers and then confirms.
511
+ *
512
+ * That is exactly the state `.claude/rules/destructive.md` refuses in its own words: a cascade
513
+ * turns one confirmed click into an unknown number of deletions, and "the question can no
514
+ * longer name what disappears". Here the question could not be ASKED, which is the same gap one
515
+ * step earlier — and the half that was missing was the safe half.
516
+ *
517
+ * ⚠️ It answers about a tree, not about one row: `totalItems` counts the nodes AND the flows
518
+ * beneath it, the same two tables the purge itself walks. `inboundLinks` is the other kind of
519
+ * consequence — mentions elsewhere that will break and are deliberately not refused.
520
+ */
521
+ server.registerTool("node_purge_preview", {
522
+ title: "Preview a permanent deletion",
523
+ description: "Count what deleting one ARCHIVED node for good would take with it: every item in its subtree, documents and flows alike, and how many documents elsewhere link to it. It changes nothing. Ask before node_purge — afterwards there is no row left to count, and the links that break are not refused, only reported.",
524
+ inputSchema: PurgeNodePreviewInput,
525
+ annotations: {
526
+ title: "Preview a permanent deletion",
527
+ readOnlyHint: true,
528
+ destructiveHint: false,
529
+ idempotentHint: true,
530
+ openWorldHint: false,
531
+ },
532
+ }, async (input) => text(await deps.nodes.purgePreview(actor, input)));
467
533
  // ⚠️ It exists on this surface for the reason #436 states: a way only the interface has is a way
468
534
  // an agent goes around, by looking for the row in the database. What it may do is bounded by the
469
535
  // same rules the interface obeys — only an archived node, only an empty folder, never one a flow
@@ -498,6 +564,35 @@ export async function handleMcp(request, deps) {
498
564
  openWorldHint: false,
499
565
  },
500
566
  }, async (input) => text(await deps.nodes.listGrants(actor, input.resourceId)));
567
+ /**
568
+ * ⚠️ The neighbour above answers a NARROWER question, and that is why this one has to be here
569
+ * (#586). `node_grant_list` reads the rows on this node; this resolves the inheritance — every
570
+ * grant along the folders above plus the owners, who reach it without a grant row existing at
571
+ * all. On the everyday node, whose access comes from the folder it sits in, the first answer is
572
+ * empty and this one is not.
573
+ *
574
+ * Two queries with similar names are two different questions, and `.claude/rules/destructive.md`
575
+ * records what it costs to confuse them: in #457 a refusal asked the one that sounded right and
576
+ * never fired. Here the cost is the same shape and lands on the sharing path — an agent asked
577
+ * "who can see this" reaches for the only name it has, is answered `[]`, and reports that
578
+ * nobody can, about a document the whole organization opens. Silence would not be a missing
579
+ * answer here, it would be a confident wrong one.
580
+ *
581
+ * ⚠️ The list is for reading, never for revoking: only the entries whose `resourceId` is this
582
+ * node can be revoked here, the rest belong to the folder that granted them.
583
+ */
584
+ server.registerTool("node_effective_access_list", {
585
+ title: "List effective node access",
586
+ description: "List everybody who reaches one node once inheritance is resolved: the grants on it AND on every folder above it, plus the owners, who need no grant. node_grant_list answers the narrower question — the rows sitting on this node alone — and on a node that inherits its access that answer is empty while this one is not. An entry whose resourceId is another node was granted there and can only be revoked there.",
587
+ inputSchema: ListEffectiveAccessInput,
588
+ annotations: {
589
+ title: "List effective node access",
590
+ readOnlyHint: true,
591
+ destructiveHint: false,
592
+ idempotentHint: true,
593
+ openWorldHint: false,
594
+ },
595
+ }, async (input) => text(await deps.nodes.listEffectiveAccess(actor, input.resourceId)));
501
596
  server.registerTool("node_grant_create", {
502
597
  title: "Grant node access",
503
598
  description: "Grant inherited node access to a Gate user, verified email, or the organization.",
@@ -521,7 +616,7 @@ export async function handleMcp(request, deps) {
521
616
  idempotentHint: true,
522
617
  openWorldHint: false,
523
618
  },
524
- }, async (input) => text(await deps.nodes.revokeGrant(actor, input)));
619
+ }, async (input) => await refusing(() => deps.nodes.revokeGrant(actor, input)));
525
620
  }
526
621
  if (permits(deps.authorization, "flows", "read")) {
527
622
  server.registerTool("flow_list", {
@@ -681,6 +776,24 @@ export async function handleMcp(request, deps) {
681
776
  openWorldHint: false,
682
777
  },
683
778
  }, async (input) => text(await deps.flows.listGrants(flowActor, input.flowId)));
779
+ // ⚠️ The flow half of the node tool three groups up, and it is a SECOND tool rather than a
780
+ // widened one because it is a second question (#586): the answer is assembled from `flow_grants`
781
+ // and from the node grants of the folders above, and the owners come from the flow itself as
782
+ // well as from those folders. One tool over a `resourceId` that means either would have to guess
783
+ // which table to ask, and a caller passing the wrong kind would be answered "nobody" instead of
784
+ // being told.
785
+ server.registerTool("flow_effective_access_list", {
786
+ title: "List effective flow access",
787
+ description: "List everybody who reaches one flow once inheritance is resolved: the grants on the flow AND on the folders it is filed under, plus the owners, who need no grant. flow_grant_list answers the narrower question — the rows sitting on the flow itself — and for a flow whose access comes from its folder that answer is empty while this one is not. An entry whose resourceId is a folder was granted there and can only be revoked there.",
788
+ inputSchema: ListFlowEffectiveAccessInput,
789
+ annotations: {
790
+ title: "List effective flow access",
791
+ readOnlyHint: true,
792
+ destructiveHint: false,
793
+ idempotentHint: true,
794
+ openWorldHint: false,
795
+ },
796
+ }, async (input) => text(await deps.flows.listEffectiveAccess(flowActor, input.flowId)));
684
797
  server.registerTool("flow_grant_create", {
685
798
  title: "Grant flow access",
686
799
  description: "Grant access to one flow for a Gate user, verified email, or the organization. The grant reaches this flow alone — not the folder it is filed in, and not the flows it calls; the answer names what it does not cover.",
@@ -761,7 +874,7 @@ export async function handleMcp(request, deps) {
761
874
  idempotentHint: false,
762
875
  openWorldHint: false,
763
876
  },
764
- }, async (input) => text(await deps.flows.purge(flowActor, input)));
877
+ }, async (input) => await refusing(() => deps.flows.purge(flowActor, input)));
765
878
  server.registerTool("flow_version_create", {
766
879
  title: "Create flow version",
767
880
  description: "Append a validated immutable flow graph version with optimistic concurrency.",
@@ -1,5 +1,7 @@
1
1
  import { TableMediaType } from "@anchrd/intel-contract/table";
2
+ import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
2
3
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
4
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
3
5
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
6
  import { plainTitle } from "../shared/plain-title/plain-title.js";
5
7
  import { documentLinkTargets } from "./document-links/document-links.js";
@@ -27,13 +29,15 @@ function applicableVerbs(kind) {
27
29
  }
28
30
  // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
29
31
  // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
30
- // see are named and the rest are only counted (ADR-0004 §3, and #17's review).
32
+ // see are named and the rest are only counted (ADR-0004 §3, and #17's review) — which is the whole
33
+ // of what `callersPhrase` does, in the one place both refusals read it from.
34
+ //
35
+ // The sentence stays for whoever has nothing to formulate with — a model over MCP reads it as it
36
+ // stands — and `namedOrCounted` carries the same decision as data, so a screen can say it in its
37
+ // own language (#448). Both come out of one place: the split between named and counted is an
38
+ // authorization answer, and a surface that recomputed it would be deciding what it may not.
31
39
  function callersDetail(callers) {
32
- const named = callers.visible.map((title) => `“${title}”`).join(", ");
33
- const rest = callers.hidden === 0
34
- ? ""
35
- : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
36
- return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
40
+ return `Flows outside this folder call into it: ${callersPhrase(callers)}. Change or unpublish them before narrowing the folder.`;
37
41
  }
38
42
  // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
39
43
  // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
@@ -1025,6 +1029,20 @@ export function createNodes(deps) {
1025
1029
  };
1026
1030
  }
1027
1031
  }
1032
+ /**
1033
+ * ⚠️ Behind the replay and in front of `setGrant` — both halves matter (#442).
1034
+ *
1035
+ * In front of `setGrant` is the rule itself: nothing that could never work gets written, so
1036
+ * no row, no idempotency key and no audit event. Every path that reaches the write passes
1037
+ * here, including a replay whose grant was revoked in between and falls through.
1038
+ *
1039
+ * Behind the replay because otherwise this refusal would break the promise `IdempotencyKey`
1040
+ * makes. A caller retries with the SAME key and the SAME body; if the retry arrives after the
1041
+ * expiry the first attempt named, the value is no longer in the future — and the second call
1042
+ * would be refused for a grant that is already written. The replay path writes nothing, so
1043
+ * standing behind it costs the rule nothing and keeps the retry answering with what happened.
1044
+ */
1045
+ requireFutureExpiry(input.expiresAt, deps.now());
1028
1046
  const timestamp = deps.now().toISOString();
1029
1047
  const grant = await deps.repository.setGrant({
1030
1048
  grant: {
@@ -1070,7 +1088,7 @@ export function createNodes(deps) {
1070
1088
  !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
1071
1089
  const callers = await deps.externalFlowCallers(actor, input.resourceId);
1072
1090
  if (callers.visible.length || callers.hidden) {
1073
- throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
1091
+ throw new IntelError(409, "folder_execute_in_use", callersDetail(callers), namedOrCounted(callers));
1074
1092
  }
1075
1093
  }
1076
1094
  return {
@@ -0,0 +1,28 @@
1
+ import type { NamedOrCounted } from "@anchrd/intel-contract";
2
+ type Callers = {
3
+ visible: string[];
4
+ hidden: number;
5
+ };
6
+ /**
7
+ * ⚠️ Named where the reader may see it, counted where they may not (ADR-0004 §3). A refusal has to
8
+ * be actionable without becoming a way of reading the tree: whoever holds one grant on one folder
9
+ * must not learn the titles of flows they are refused directly.
10
+ *
11
+ * This is the middle of both refusals that ask the question — narrowing a folder (`nodes.ts`) and
12
+ * deleting a flow for good (`flows.ts`). ⚠️ **The two SENTENCES around it stay two**, because they
13
+ * say different things to different people: one is about a folder that is about to lose a grant,
14
+ * the other about a flow that is about to be gone. Folding them into one parameterised sentence
15
+ * would buy a shared string and cost the reason each one gives.
16
+ */
17
+ export declare function callersPhrase(callers: Callers): string;
18
+ /**
19
+ * The same two facts the sentence is built from, in the shape a grant's own warning already uses
20
+ * (`UnreadableNodes`, #448). One question — what may you see, and of how much — deserves one answer
21
+ * shape on every surface, and a screen that has to write the sentence itself needs the facts and
22
+ * not the sentence.
23
+ *
24
+ * ⚠️ It reads the SAME argument the phrase above reads. A surface that recomputed the split would
25
+ * be deciding what it may not, and a second decision here would be the one that drifts.
26
+ */
27
+ export declare function namedOrCounted(callers: Callers): NamedOrCounted;
28
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ⚠️ Named where the reader may see it, counted where they may not (ADR-0004 §3). A refusal has to
3
+ * be actionable without becoming a way of reading the tree: whoever holds one grant on one folder
4
+ * must not learn the titles of flows they are refused directly.
5
+ *
6
+ * This is the middle of both refusals that ask the question — narrowing a folder (`nodes.ts`) and
7
+ * deleting a flow for good (`flows.ts`). ⚠️ **The two SENTENCES around it stay two**, because they
8
+ * say different things to different people: one is about a folder that is about to lose a grant,
9
+ * the other about a flow that is about to be gone. Folding them into one parameterised sentence
10
+ * would buy a shared string and cost the reason each one gives.
11
+ */
12
+ export function callersPhrase(callers) {
13
+ const named = callers.visible.map((title) => `“${title}”`).join(", ");
14
+ const rest = callers.hidden === 0
15
+ ? ""
16
+ : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
17
+ return `${named}${rest}`;
18
+ }
19
+ /**
20
+ * The same two facts the sentence is built from, in the shape a grant's own warning already uses
21
+ * (`UnreadableNodes`, #448). One question — what may you see, and of how much — deserves one answer
22
+ * shape on every surface, and a screen that has to write the sentence itself needs the facts and
23
+ * not the sentence.
24
+ *
25
+ * ⚠️ It reads the SAME argument the phrase above reads. A surface that recomputed the split would
26
+ * be deciding what it may not, and a second decision here would be the one that drifts.
27
+ */
28
+ export function namedOrCounted(callers) {
29
+ return { titles: callers.visible, hidden: callers.hidden };
30
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ⚠️ A grant whose expiry has already passed is refused, never written (#442).
3
+ *
4
+ * It would otherwise be created, answered with a `201`, and listed — while reaching nobody. The
5
+ * sharer reads the row as done; the grantee gets no notification and finds out days later, if at
6
+ * all. That is the failure mode this repository keeps meeting: something that looks like success,
7
+ * and the only feedback is the wrong one.
8
+ *
9
+ * ⚠️ The check cannot live in the Zod schema. That boundary knows nothing about `deps.now()`, and
10
+ * the rule is decided by the very clock that evaluates the grant afterwards — so it belongs in the
11
+ * application layer, where every surface passes through it. The `.describe()` on `expiresAt`
12
+ * documents the rule; this refuses it.
13
+ *
14
+ * ⚠️ Equal to `now` is refused too: a grant that expires this instant is spent before the answer
15
+ * reaches the caller.
16
+ *
17
+ * `null` is untouched and stays what it always meant — a grant that does not expire on its own.
18
+ */
19
+ export declare function requireFutureExpiry(expiresAt: string | null, now: Date): void;
@@ -0,0 +1,26 @@
1
+ import { IntelError } from "../intel-error/intel-error.js";
2
+ /**
3
+ * ⚠️ A grant whose expiry has already passed is refused, never written (#442).
4
+ *
5
+ * It would otherwise be created, answered with a `201`, and listed — while reaching nobody. The
6
+ * sharer reads the row as done; the grantee gets no notification and finds out days later, if at
7
+ * all. That is the failure mode this repository keeps meeting: something that looks like success,
8
+ * and the only feedback is the wrong one.
9
+ *
10
+ * ⚠️ The check cannot live in the Zod schema. That boundary knows nothing about `deps.now()`, and
11
+ * the rule is decided by the very clock that evaluates the grant afterwards — so it belongs in the
12
+ * application layer, where every surface passes through it. The `.describe()` on `expiresAt`
13
+ * documents the rule; this refuses it.
14
+ *
15
+ * ⚠️ Equal to `now` is refused too: a grant that expires this instant is spent before the answer
16
+ * reaches the caller.
17
+ *
18
+ * `null` is untouched and stays what it always meant — a grant that does not expire on its own.
19
+ */
20
+ export function requireFutureExpiry(expiresAt, now) {
21
+ if (expiresAt === null)
22
+ return;
23
+ if (Date.parse(expiresAt) > now.getTime())
24
+ return;
25
+ throw new IntelError(400, "grant_already_expired", "A grant's expiry has to lie in the future; pass null for one that does not expire on its own");
26
+ }
@@ -1,5 +1,22 @@
1
+ import type { NamedOrCounted } from "@anchrd/intel-contract";
1
2
  export declare class IntelError extends Error {
2
3
  readonly status: number;
3
4
  readonly code: string;
4
- constructor(status: number, code: string, message: string);
5
+ readonly callers?: NamedOrCounted | undefined;
6
+ /**
7
+ * ⚠️ `callers` is what a refusal that NAMES flows carries beside its sentence — today
8
+ * `folder_execute_in_use` (#448) and `flow_in_use_by_flow` (#593). It rides on the error rather
9
+ * than on the route because the split it belongs to is an application-layer one: who may be named
10
+ * and who may only be counted is an authorization answer, and it must be the same over HTTP, over
11
+ * MCP and in the browser. A route that assembled it would be a second place for that rule to live.
12
+ *
13
+ * `message` still carries the finished English sentence — a reader that only takes text must lose
14
+ * nothing — and the field is what lets a screen say it in its own language instead.
15
+ *
16
+ * ⚠️ **Setting this field is only half of reaching MCP.** The SDK formats a thrown error through
17
+ * `createToolError`, which keeps `message` and drops everything else, so a tool whose handler is
18
+ * not wrapped in `refusing()` (`mcp/mcp.ts`) answers prose and nothing more (#597). Whoever
19
+ * attaches `callers` to a new refusal wraps that tool's handler in the same change.
20
+ */
21
+ constructor(status: number, code: string, message: string, callers?: NamedOrCounted | undefined);
5
22
  }
@@ -1,10 +1,27 @@
1
1
  export class IntelError extends Error {
2
2
  status;
3
3
  code;
4
- constructor(status, code, message) {
4
+ callers;
5
+ /**
6
+ * ⚠️ `callers` is what a refusal that NAMES flows carries beside its sentence — today
7
+ * `folder_execute_in_use` (#448) and `flow_in_use_by_flow` (#593). It rides on the error rather
8
+ * than on the route because the split it belongs to is an application-layer one: who may be named
9
+ * and who may only be counted is an authorization answer, and it must be the same over HTTP, over
10
+ * MCP and in the browser. A route that assembled it would be a second place for that rule to live.
11
+ *
12
+ * `message` still carries the finished English sentence — a reader that only takes text must lose
13
+ * nothing — and the field is what lets a screen say it in its own language instead.
14
+ *
15
+ * ⚠️ **Setting this field is only half of reaching MCP.** The SDK formats a thrown error through
16
+ * `createToolError`, which keeps `message` and drops everything else, so a tool whose handler is
17
+ * not wrapped in `refusing()` (`mcp/mcp.ts`) answers prose and nothing more (#597). Whoever
18
+ * attaches `callers` to a new refusal wraps that tool's handler in the same change.
19
+ */
20
+ constructor(status, code, message, callers) {
5
21
  super(message);
6
22
  this.status = status;
7
23
  this.code = code;
24
+ this.callers = callers;
8
25
  this.name = "IntelError";
9
26
  }
10
27
  }
@@ -1,3 +1,4 @@
1
+ import type { IntelError } from "../intel-error/intel-error.js";
1
2
  export declare function problemDetails(status: number, code: string, title: string, detail?: string): {
2
3
  type: string;
3
4
  status: number;
@@ -5,4 +6,25 @@ export declare function problemDetails(status: number, code: string, title: stri
5
6
  detail?: string | undefined;
6
7
  instance?: string | undefined;
7
8
  code?: string | undefined;
9
+ callers?: {
10
+ titles: string[];
11
+ hidden: number;
12
+ } | undefined;
13
+ };
14
+ /**
15
+ * One refusal, one document — written here rather than at each `onError`, because two apps turn an
16
+ * `IntelError` into a body and an extension member added at one of them would silently be missing
17
+ * from the other (#448). Whatever the application layer decided to say travels with it.
18
+ */
19
+ export declare function intelProblem(error: IntelError): {
20
+ type: string;
21
+ status: number;
22
+ title: string;
23
+ detail?: string | undefined;
24
+ instance?: string | undefined;
25
+ code?: string | undefined;
26
+ callers?: {
27
+ titles: string[];
28
+ hidden: number;
29
+ } | undefined;
8
30
  };
@@ -2,3 +2,17 @@ import { ProblemDetails } from "@anchrd/intel-contract";
2
2
  export function problemDetails(status, code, title, detail) {
3
3
  return ProblemDetails.parse({ type: "about:blank", status, code, title, detail });
4
4
  }
5
+ /**
6
+ * One refusal, one document — written here rather than at each `onError`, because two apps turn an
7
+ * `IntelError` into a body and an extension member added at one of them would silently be missing
8
+ * from the other (#448). Whatever the application layer decided to say travels with it.
9
+ */
10
+ export function intelProblem(error) {
11
+ return ProblemDetails.parse({
12
+ type: "about:blank",
13
+ status: error.status,
14
+ code: error.code,
15
+ title: error.message,
16
+ callers: error.callers,
17
+ });
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.24.0",
3
+ "version": "0.26.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.15.0",
46
- "@anchrd/intel-contract": "^0.20.0",
45
+ "@anchrd/gate-sdk": "^0.20.0",
46
+ "@anchrd/intel-contract": "^0.22.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",