@anchrd/intel-api 0.23.0 → 0.25.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.
@@ -97,7 +97,35 @@ export function createIntel(deps) {
97
97
  return context.redirect("/tools?connectError=portal_sign_in_failed");
98
98
  }
99
99
  });
100
- app.get("/auth/callback", async (context) => await browserAuth.callback(new URL(context.req.url), context.req.raw.headers));
100
+ // The other end of the same walk: `/auth/connect` starts it, `/auth/callback` finishes it, and
101
+ // a reader who lands in a white JSON page does not distinguish which of the two put them there
102
+ // (#444). Everything that reaches this catch has already lost its handoff — `callback` answers
103
+ // the paths that still know where the person wanted to go — so the destination here is a
104
+ // DECIDED one rather than a remembered one.
105
+ //
106
+ // ⚠️ `/tools` and not `/`: it is the only screen that reads `connectError` and turns it into a
107
+ // sentence (`portalAnswerOf`), and every code but the two about access lands on "the sign-in is
108
+ // broken" there — which is what happened. The root would take the reader somewhere that says
109
+ // nothing at all about the attempt they just made. Somebody who has no Intel session either is
110
+ // sent on to the login by the interface's own 401 handling, once, bounded by the sign-in loop
111
+ // guard (#118).
112
+ app.get("/auth/callback", async (context) => {
113
+ try {
114
+ return await browserAuth.callback(new URL(context.req.url), context.req.raw.headers);
115
+ }
116
+ catch (error) {
117
+ // ⚠️ The IntelError is logged here although `app.onError` deliberately does not log one:
118
+ // an expected refusal explains itself through its body, and this route no longer has a
119
+ // body. `oauth_session_invalid` is raised for two different situations — no readable
120
+ // handoff at all, and a Gate login handoff that ran out — and a deployment where EVERY
121
+ // callback fails (a rotated session key, a clock that drifted, a cookie the browser stopped
122
+ // sending) looks from the outside exactly like one person with a stale tab. Without this
123
+ // line nothing anywhere tells the two apart.
124
+ reportUnexpectedError(error);
125
+ const code = error instanceof IntelError ? error.code : "portal_sign_in_failed";
126
+ return context.redirect(`/tools?connectError=${encodeURIComponent(code)}`);
127
+ }
128
+ });
101
129
  app.post("/auth/logout", () => browserAuth.logout());
102
130
  }
103
131
  app.route("/api/v1", createHttp({
package/dist/mcp/mcp.js CHANGED
@@ -2,7 +2,7 @@ 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
4
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
- import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
5
+ import { 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";
@@ -666,8 +666,46 @@ export async function handleMcp(request, deps) {
666
666
  },
667
667
  }, async (input) => text(await deps.flows.listRunSteps(flowActor, input.runId)));
668
668
  }
669
- // A flow has no share tool of its own. Sharing happens on the folder a flow is filed in, through
670
- // node_grant_create, so a narrower grant cannot sit beside the folder grant (ADR-0004 §2).
669
+ // The flow's own sharing (#530), named by the same grammar as the node tools: domain singular,
670
+ // the way narrowing left to right, the verb last.
671
+ if (permits(deps.authorization, "flows", "share")) {
672
+ server.registerTool("flow_grant_list", {
673
+ title: "List flow grants",
674
+ description: "List the grants sitting on one flow. What the folder it is filed in passes down is not listed here.",
675
+ inputSchema: ListFlowGrantsInput,
676
+ annotations: {
677
+ title: "List flow grants",
678
+ readOnlyHint: true,
679
+ destructiveHint: false,
680
+ idempotentHint: true,
681
+ openWorldHint: false,
682
+ },
683
+ }, async (input) => text(await deps.flows.listGrants(flowActor, input.flowId)));
684
+ server.registerTool("flow_grant_create", {
685
+ title: "Grant flow access",
686
+ 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.",
687
+ inputSchema: ShareFlowInput,
688
+ annotations: {
689
+ title: "Grant flow access",
690
+ readOnlyHint: false,
691
+ destructiveHint: false,
692
+ idempotentHint: true,
693
+ openWorldHint: false,
694
+ },
695
+ }, async (input) => text(await deps.flows.share(flowActor, input)));
696
+ server.registerTool("flow_grant_revoke", {
697
+ title: "Revoke flow grant",
698
+ description: "Revoke one grant sitting on a flow by ID.",
699
+ inputSchema: RevokeFlowGrantInput,
700
+ annotations: {
701
+ title: "Revoke flow grant",
702
+ readOnlyHint: false,
703
+ destructiveHint: true,
704
+ idempotentHint: true,
705
+ openWorldHint: false,
706
+ },
707
+ }, async (input) => text(await deps.flows.revokeGrant(flowActor, input)));
708
+ }
671
709
  if (permits(deps.authorization, "flows", "create")) {
672
710
  server.registerTool("flow_create", {
673
711
  title: "Create flow",
@@ -1,5 +1,6 @@
1
1
  import { TableMediaType } from "@anchrd/intel-contract/table";
2
2
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
3
4
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
5
  import { plainTitle } from "../shared/plain-title/plain-title.js";
5
6
  import { documentLinkTargets } from "./document-links/document-links.js";
@@ -10,6 +11,11 @@ import { documentLinkTargets } from "./document-links/document-links.js";
10
11
  function contentKeyFor(nodeId, versionId) {
11
12
  return `nodes/${nodeId}/versions/${versionId}`;
12
13
  }
14
+ // A node grant covers every flow beneath the node it sits on, so there is never a sub-flow it fails
15
+ // to reach and this half of the warning is empty here by construction (#530). Written out rather
16
+ // than left to the schema's default: nothing parses a service's answer on the way out, so a default
17
+ // would arrive at the UI as `undefined`.
18
+ const nothingWithheld = { titles: [], hidden: 0 };
13
19
  // A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
14
20
  // The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
15
21
  //
@@ -1016,9 +1022,24 @@ export function createNodes(deps) {
1016
1022
  return {
1017
1023
  grant: replayed,
1018
1024
  unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
1025
+ unrunnable: nothingWithheld,
1019
1026
  };
1020
1027
  }
1021
1028
  }
1029
+ /**
1030
+ * ⚠️ Behind the replay and in front of `setGrant` — both halves matter (#442).
1031
+ *
1032
+ * In front of `setGrant` is the rule itself: nothing that could never work gets written, so
1033
+ * no row, no idempotency key and no audit event. Every path that reaches the write passes
1034
+ * here, including a replay whose grant was revoked in between and falls through.
1035
+ *
1036
+ * Behind the replay because otherwise this refusal would break the promise `IdempotencyKey`
1037
+ * makes. A caller retries with the SAME key and the SAME body; if the retry arrives after the
1038
+ * expiry the first attempt named, the value is no longer in the future — and the second call
1039
+ * would be refused for a grant that is already written. The replay path writes nothing, so
1040
+ * standing behind it costs the rule nothing and keeps the retry answering with what happened.
1041
+ */
1042
+ requireFutureExpiry(input.expiresAt, deps.now());
1022
1043
  const timestamp = deps.now().toISOString();
1023
1044
  const grant = await deps.repository.setGrant({
1024
1045
  grant: {
@@ -1037,7 +1058,11 @@ export function createNodes(deps) {
1037
1058
  // ⚠️ After the grant is written, never before. The answer has to describe the access that is
1038
1059
  // now in force — sharing `read` on this folder is exactly what makes the documents inside it
1039
1060
  // readable, and a warning computed a moment earlier would name them all.
1040
- return { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
1061
+ return {
1062
+ grant,
1063
+ unreadable: await unreadableForPrincipal(actor, node.id, principal),
1064
+ unrunnable: nothingWithheld,
1065
+ };
1041
1066
  },
1042
1067
  async revokeGrant(actor, input) {
1043
1068
  await requireVisible(actor, input.resourceId);
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ -- #530: a flow is shared on its own again, the way a document and a table are.
2
+ --
3
+ -- `0003` moved every per-flow grant onto a folder because ADR-0004 §3 promised something a narrower
4
+ -- grant would have broken: "everything A calls is covered by the same grant — automatically, and
5
+ -- for all time". The word that carried the promise was *automatically*. A grant on the flow alone
6
+ -- does not reach the sub-flow filed beside it, and in 2026-08 the run would simply have stopped at
7
+ -- that step, without anyone having been told beforehand.
8
+ --
9
+ -- Being told beforehand is what has been built since: `flow_validate` gathers everything standing
10
+ -- between one person and one run — missing `execute`, an unreachable sub-flow, an unreachable tool,
11
+ -- an unreadable tree link — and answers it per person and per moment, on all three surfaces. The
12
+ -- promise is therefore not restored here; it is replaced by a question anybody can ask. ADR-0004 §2
13
+ -- carries the dated amendment, D49 the reason.
14
+ --
15
+ -- ⚠️ A table of its own, not a widened `node_grants`. A flow is not a node — that is the one thing
16
+ -- ADR-0004 §1 was most explicit about — and `node_grants.node_id` carries a REFERENCES clause into
17
+ -- `nodes`. Making the column polymorphic would mean dropping that clause, and an ACL table whose
18
+ -- rows can outlive their subject is precisely where a stale row keeps handing out access.
19
+ --
20
+ -- Everything else is deliberately identical to `node_grants`, down to the index shape: the two are
21
+ -- read by one predicate builder (`db-grants.ts`), and a difference here would become a difference
22
+ -- in the questions it can ask.
23
+ CREATE TABLE flow_grants (
24
+ id TEXT PRIMARY KEY NOT NULL,
25
+ flow_id TEXT NOT NULL REFERENCES flows(id),
26
+ principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'email', 'organization')),
27
+ principal_id TEXT NOT NULL,
28
+ verb TEXT NOT NULL CHECK (verb IN ('read', 'write', 'execute', 'share')),
29
+ expires_at TEXT,
30
+ created_by TEXT NOT NULL,
31
+ created_at TEXT NOT NULL,
32
+ -- The verb belongs in the key, for the reason `0003` gives: without it one principal holds one
33
+ -- verb per flow, which is the ladder the four independent verbs exist instead of.
34
+ UNIQUE (flow_id, principal_type, principal_id, verb)
35
+ );
36
+
37
+ CREATE INDEX flow_grants_principal_idx ON flow_grants(
38
+ principal_type,
39
+ principal_id,
40
+ verb,
41
+ expires_at
42
+ );
43
+
44
+ CREATE INDEX flow_grants_flow_idx ON flow_grants(flow_id, verb);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.19.0",
45
+ "@anchrd/gate-sdk": "^0.19.0",
46
+ "@anchrd/intel-contract": "^0.21.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",