@anchrd/intel-api 0.24.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.
package/README.md CHANGED
@@ -75,12 +75,29 @@ values it stops with `Error: GATE_URL and GATE_SERVICE_KEY must be set.` before
75
75
  It declares five interfaces — `intel`, `nodes`, `flows`, `tools`, `mcp` — with the functions listed
76
76
  under *Permission, layer one* below. It is idempotent and safe to repeat after an upgrade.
77
77
 
78
- ⚠️ **`bootstrap` declares interfaces and changes no grant.** It creates the permissions an
79
- administrator can hand out; it hands out none of them. This is step 8, and it is why a fresh
80
- installation shows an empty screen to everybody including the person who installed it.
78
+ ⚠️ **`bootstrap` hands out no grant, and it takes none away as long as the declared list does not
79
+ change.** It creates the permissions an administrator can hand out; it hands out none of them. This
80
+ is step 8, and it is why a fresh installation shows an empty screen to everybody including the
81
+ person who installed it.
81
82
 
82
- ⚠️ It also never revokes. An interface an older version declared stays declared in Gate; that is
83
- harmless, and removing one is an act in Gate, by hand.
83
+ ⚠️ **But a function that falls out of a still-declared interface takes its grants with it.**
84
+ Declaring is an upsert that REPLACES an interface's function list, and Gate deletes every grant on a
85
+ function the new list no longer names. So an upgrade that retires a function also retires every role
86
+ assignment on it — against a running installation that is a change to who may do what, not a
87
+ read. `bootstrap` reads the catalog before it writes and names each one as it goes:
88
+
89
+ ```text
90
+ Removed flows:approve from Gate; every grant on it was deleted with it.
91
+ Declared 5 Intel interfaces in Gate.
92
+ ```
93
+
94
+ A run that dropped nothing says so instead: `No declared function was dropped, so Gate deleted no
95
+ grant.` And if the catalog cannot be read, `bootstrap` writes nothing at all rather than delete
96
+ grants it would then be unable to name.
97
+
98
+ ⚠️ It never revokes a whole **interface**. One an older version declared stays declared in Gate:
99
+ `bootstrap` writes only the handles it names, so an interface outside that list keeps its functions
100
+ and its grants. That is harmless, and removing one is an act in Gate, by hand.
84
101
 
85
102
  ### 4. Copy the migrations out of `node_modules`
86
103
 
@@ -135,6 +152,19 @@ FAIL TOOL_SOURCE_ORIGINS is missing
135
152
  FAIL INTEL_SESSION_SECRET must contain at least 32 bytes
136
153
  ```
137
154
 
155
+ It also compares the Gate interfaces **in both directions**. A function or a whole interface Gate
156
+ carries that this version of Intel no longer declares is reported as a `NOTE`, never as a `FAIL`:
157
+ nothing is broken by it, and a check one knows red stops being read. Each note names the surplus,
158
+ why it went, and what to do about it:
159
+
160
+ ```text
161
+ NOTE Gate carries flows:approve, which Intel does not declare — the approval node is gone (#73).
162
+ NOTE Gate carries the interface knowledge (read, write), which Intel does not declare — the whole interface became `nodes` (#152, after #125).
163
+ NOTE The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.
164
+ NOTE `bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.
165
+ OK Intel packages, configuration, and Gate interfaces are ready.
166
+ ```
167
+
138
168
  ⚠️ **`doctor` cannot see the Worker's secrets.** It answers about the shell it runs in, so it says
139
169
  nothing about whether the deployed Worker is configured. `GET /health` on the deployed Worker
140
170
  answers `{"status":"ok"}`; a Worker missing one of the five required variables answers
@@ -255,10 +285,10 @@ grants them.
255
285
 
256
286
  | Interface | Functions |
257
287
  |---|---|
258
- | `intel` | `use`, `admin` |
288
+ | `intel` | `admin` |
259
289
  | `nodes` | `read`, `create`, `write`, `share` |
260
290
  | `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
261
- | `tools` | `read`, `test`, `execute`, `admin` |
291
+ | `tools` | `read`, `test`, `execute` |
262
292
  | `mcp` | `connect` |
263
293
 
264
294
  A missing capability answers **`403`**, and the screen says a permission is missing.
@@ -607,6 +607,22 @@ export function createFlowRepository(deps) {
607
607
  .all();
608
608
  return (result.results ?? []).map((row) => row.resource_id);
609
609
  },
610
+ // ⚠️ No actor and no `archived_at` condition, both on purpose (#509). The question is whether
611
+ // the row is still there at all, and archiving keeps it — a reference to something archived is
612
+ // not broken, it is waiting for a restore. The caller has already asked the visibility door for
613
+ // every one of these ids and reaches this only for the ones it answered nothing for.
614
+ //
615
+ // ⚠️ An empty list short-circuits rather than building `IN ()`, which SQLite refuses to parse.
616
+ async existingNodes(nodeIds) {
617
+ const wanted = [...new Set(nodeIds)];
618
+ if (wanted.length === 0)
619
+ return [];
620
+ const result = await deps.db
621
+ .prepare(`SELECT id FROM nodes WHERE id IN (${wanted.map(() => "?").join(", ")}) ORDER BY id`)
622
+ .bind(...wanted)
623
+ .all();
624
+ return (result.results ?? []).map((row) => row.id);
625
+ },
610
626
  async publishedCallees(flowId) {
611
627
  const row = await deps.db
612
628
  .prepare(`SELECT flow.title AS title, version.graph_json AS graph_json
package/dist/auth/auth.js CHANGED
@@ -218,28 +218,55 @@ export function createBrowserAuth(deps) {
218
218
  ? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
219
219
  : withConnectError(pending.returnTo));
220
220
  }
221
- const tokens = await deps.oauth.exchange({
222
- issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
223
- clientId: pending.clientId,
224
- callbackUrl: requestUrl,
225
- redirectUri,
226
- resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
227
- codeVerifier: pending.codeVerifier,
228
- state: pending.state,
229
- });
230
- if (pending.kind === "connection-pending") {
231
- await deps.portalTokens.write(pending.userId, {
232
- accessToken: tokens.accessToken,
233
- refreshToken: tokens.refreshToken ?? null,
234
- expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
235
- issuer: pending.issuer,
221
+ // ⚠️ Everything below runs with the handoff READ, and that is the whole difference between
222
+ // this catch and the one on the route (#444): here a failure knows where the person was
223
+ // going, and for a connection handoff it still holds their parked Intel session. The route
224
+ // can only send somebody to a decided screen, so whatever can be answered here is answered
225
+ // here — the token exchange, the portal token write and the sealing of the new session all
226
+ // used to end as a bare 500 in JSON, in the middle of a sign-in.
227
+ try {
228
+ const tokens = await deps.oauth.exchange({
229
+ issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
236
230
  clientId: pending.clientId,
237
- resource: pending.resource,
231
+ callbackUrl: requestUrl,
232
+ redirectUri,
233
+ resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
234
+ codeVerifier: pending.codeVerifier,
235
+ state: pending.state,
238
236
  });
239
- return await restore(pending.user, pending.returnTo, pending.returnTo);
237
+ if (pending.kind === "connection-pending") {
238
+ await deps.portalTokens.write(pending.userId, {
239
+ accessToken: tokens.accessToken,
240
+ refreshToken: tokens.refreshToken ?? null,
241
+ expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
242
+ issuer: pending.issuer,
243
+ clientId: pending.clientId,
244
+ resource: pending.resource,
245
+ });
246
+ return await restore(pending.user, pending.returnTo, pending.returnTo);
247
+ }
248
+ const session = await userSession(pending.clientId, tokens);
249
+ return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
250
+ }
251
+ catch (error) {
252
+ // The exception is the only thing that still holds the reason: what the reader gets is a
253
+ // fixed marker, and a marker names a KIND of failure, never its cause (#93).
254
+ reportUnexpectedError(error);
255
+ // `portal_sign_in_failed` is the marker `/auth/connect` already writes for exactly this
256
+ // situation, so nothing new is invented here and no screen has to learn a word. The Tools
257
+ // screen reads it as "the sign-in is broken", which is true of both handoffs — a Gate login
258
+ // that could not be exchanged is no more the reader's doing than a portal one.
259
+ if (pending.kind === "connection-pending") {
260
+ // Every other way out of the callback puts the parked Intel session back, and so does
261
+ // this one: the portal attempt must never cost somebody their Intel login.
262
+ return await restore(pending.user, pending.returnTo, withConnectError(pending.returnTo, "portal_sign_in_failed"));
263
+ }
264
+ // A Gate login handoff parks nothing, so there is no session to put back — and the cookie
265
+ // is spent either way: its verifier belongs to an authorization code that has now been
266
+ // used. Clearing it is what makes the next attempt start a fresh one instead of replaying
267
+ // this one.
268
+ return redirect(withConnectError(pending.returnTo, "portal_sign_in_failed"), sessionCookie("", secure, 0));
240
269
  }
241
- const session = await userSession(pending.clientId, tokens);
242
- return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
243
270
  },
244
271
  logout() {
245
272
  return redirect("/", sessionCookie("", secure, 0));
@@ -13,6 +13,42 @@ const IntelConfig = z.strictObject({
13
13
  const Catalog = z.record(z.string(), z.string());
14
14
  const defaultTheme = `/* Generated by \`intel build\`. Intel defaults are active. */\n`;
15
15
  const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
16
+ // The `_headers` file of Workers Assets, written INTO THE BUILD OUTPUT (#543, sibling of
17
+ // anchrd/gate#329).
18
+ //
19
+ // Without it every asset answers with the platform default `public, max-age=0, must-revalidate`,
20
+ // and `public` lets a browser store an ERROR answer too. A `403` that appears once during setup is
21
+ // then replayed on every navigation WITHOUT a request leaving the browser — invisible in the worker
22
+ // log, in the Cloudflare Access log, in the firewall and in Cloudflare Analytics, while `curl`
23
+ // against the same address keeps answering `200`. The second rule matters more here than in Gate:
24
+ // the UI ships 1.9 MB of fingerprinted assets that are revalidated on every navigation today.
25
+ //
26
+ // ⚠️ `! Cache-Control` is not decoration, and the naive two-rule form from the ticket is WRONG.
27
+ // A request matching several rules inherits ALL their headers, and a header named twice is joined
28
+ // with a comma. Measured against `wrangler dev` 4.112.0 with both spellings side by side:
29
+ // without the detach /assets/app.js → `no-store, public, max-age=31536000, immutable`
30
+ // with the detach /assets/app.js → `public, max-age=31536000, immutable`
31
+ // The joined value carries `no-store`, so those 1.9 MB would never be cached at all — the exact
32
+ // opposite of the second rule's purpose.
33
+ //
34
+ // ⚠️ It has to be written into the TEMPORARY directory, before `replaceDir`. The next run's
35
+ // `--emptyOutDir` applies to that directory, so a file placed into the live outDir afterwards
36
+ // survives exactly one build. And `packages/ui/public/` is no way either: the `files` field of
37
+ // @anchrd/intel-ui does not list `public`, so the file would look right in the repository and never
38
+ // reach a customer.
39
+ //
40
+ // The three `cache-control` writes in this package do not contradict it: `auth/auth.ts` (redirect),
41
+ // `http/http.ts` (zipResponse, attachment bytes) all answer `no-store` under `/auth/*` and `/api/*`,
42
+ // which the assets layer never sees. Nothing serves an SPA route under `/assets/`, so the
43
+ // `immutable` rule cannot swallow one.
44
+ const headersFile = `# Generated by \`intel build\` — do not edit by hand.
45
+ /*
46
+ Cache-Control: no-store
47
+
48
+ /assets/*
49
+ ! Cache-Control
50
+ Cache-Control: public, max-age=31536000, immutable
51
+ `;
16
52
  // Customer paths from intel.json are written into generated CSS and TypeScript comments. A path
17
53
  // containing `*/` or a newline would close the comment early, leaving broken output or — in
18
54
  // custom-logo.ts — customer-controlled text outside the comment and inside the UI bundle.
@@ -130,6 +166,9 @@ export function createBuild(deps) {
130
166
  deps.log(`Error: Intel UI build exited with ${exitCode}.`);
131
167
  return 1;
132
168
  }
169
+ // Into the temporary directory, not into outDir — see `headersFile`. Vite has just emptied
170
+ // and refilled this directory; anything written here travels with the swap.
171
+ await deps.writeTextFile(`${temporaryOutDir}/_headers`, headersFile);
133
172
  await deps.replaceDir(temporaryOutDir, location.outDir);
134
173
  deps.log(`Intel UI built at ${location.outDir}.`);
135
174
  return 0;
@@ -336,6 +336,15 @@ export function createBundle(deps) {
336
336
  }
337
337
  async function tableCsv(nodeId) {
338
338
  const keys = await deps.repository.listVersionContentKeys(nodeId);
339
+ // ⚠️ `some` on an empty list is false, so no segments at all used to walk past the guard below
340
+ // and return `[].join("")` — the empty string, written into the zip as a file of zero bytes
341
+ // (#534). A table that has never been defined no longer reaches this function at all: it has no
342
+ // current version, and `plannedNode` marks it absent instead. An empty list HERE therefore means
343
+ // something else — a current version whose segments are gone — which is the loss the guard below
344
+ // already names, and it deserves the same answer rather than a silently empty file.
345
+ if (keys.length === 0) {
346
+ throw new IntelError(500, "content_missing", "Version content is missing");
347
+ }
339
348
  const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
340
349
  if (segments.some((segment) => segment === null)) {
341
350
  throw new IntelError(500, "content_missing", "Version content is missing");
@@ -351,6 +360,27 @@ export function createBundle(deps) {
351
360
  return body;
352
361
  };
353
362
  }
363
+ /**
364
+ * The bytes of an attachment version, or the same refusal the text path has always given (#560).
365
+ *
366
+ * ⚠️ A `contentKey` in the row and nothing behind it in R2 is data LOSS, and the attachment path
367
+ * was the one path that answered it with silence: `getStream` returned `null`, the zip got a file
368
+ * of zero bytes, and the export reported success. Whoever filed that zip away as a backup learns
369
+ * what it holds when they restore it — the one moment nothing can be done about it any more.
370
+ *
371
+ * This is deliberately NOT the `absence` answer. "Never uploaded" is a state of the node and
372
+ * travels in the manifest; "the object is gone" is a broken installation, and a bundle must not
373
+ * be able to describe it as if it were fine. Same distinction as `textLoader` and `tableCsv`.
374
+ */
375
+ function streamLoader(contentKey) {
376
+ return async () => {
377
+ const stream = await deps.content.getStream(contentKey);
378
+ if (stream === null) {
379
+ throw new IntelError(500, "content_missing", "Version content is missing");
380
+ }
381
+ return stream;
382
+ };
383
+ }
354
384
  function plannedNode(row, directory, used) {
355
385
  const { node, version } = row;
356
386
  const base = sanitizeName(node.title);
@@ -371,6 +401,30 @@ export function createBundle(deps) {
371
401
  }
372
402
  if (node.kind === "table") {
373
403
  const path = `${directory}${uniqueName(used, base, ".csv")}`;
404
+ // ⚠️ A table with no current version has never been given a header, and for a table the
405
+ // canonical content IS the CSV: there is nothing to write. It used to travel as a file of zero
406
+ // bytes, which the holder of the zip cannot tell apart from a table whose content was lost on
407
+ // the way — the very case `tableCsv` refuses as `content_missing`. One file, two meanings, no
408
+ // way to read which (#534).
409
+ //
410
+ // ⚠️ The entry STAYS in the manifest, and that is the half that matters: dropping it would
411
+ // take the node's title, its description and its place in the tree out of the backup too, and
412
+ // the import would recreate a subtree with a node missing from it without anybody noticing.
413
+ // What travels instead is the node without bytes, plus the word for what is not there.
414
+ if (version === null) {
415
+ return {
416
+ manifest: {
417
+ id: node.id,
418
+ kind: "table",
419
+ title: node.title,
420
+ description: node.description,
421
+ mediaType: TableMediaType,
422
+ path,
423
+ absence: "no-content",
424
+ },
425
+ content: { type: "absent" },
426
+ };
427
+ }
374
428
  return {
375
429
  manifest: {
376
430
  id: node.id,
@@ -387,19 +441,38 @@ export function createBundle(deps) {
387
441
  // Original bytes under the original name: an attachment's title IS its file name, so no
388
442
  // extension is imposed on it.
389
443
  const path = `${directory}${uniqueName(used, base, "")}`;
444
+ // ⚠️ An attachment with no current version was created and never uploaded to, and for an
445
+ // attachment the canonical content IS the bytes: there is nothing to write. It used to travel
446
+ // as a file of zero bytes — the same one file with two meanings the table branch above
447
+ // refuses, and here the second meaning is a lost R2 object (#560).
448
+ //
449
+ // ⚠️ The entry STAYS in the manifest for the same reason it does for a table: dropping it
450
+ // would take the node's title, its description and its place in the tree out of the backup,
451
+ // and the import would rebuild a subtree with a node missing from it and say nothing.
452
+ if (version === null) {
453
+ return {
454
+ manifest: {
455
+ id: node.id,
456
+ kind: "attachment",
457
+ title: node.title,
458
+ description: node.description,
459
+ mediaType: "application/octet-stream",
460
+ path,
461
+ absence: "no-content",
462
+ },
463
+ content: { type: "absent" },
464
+ };
465
+ }
390
466
  return {
391
467
  manifest: {
392
468
  id: node.id,
393
469
  kind: "attachment",
394
470
  title: node.title,
395
471
  description: node.description,
396
- mediaType: version?.mediaType ?? "application/octet-stream",
472
+ mediaType: version.mediaType ?? "application/octet-stream",
397
473
  path,
398
474
  },
399
- content: {
400
- type: "stream",
401
- load: async () => version === null ? null : await deps.content.getStream(version.contentKey),
402
- },
475
+ content: { type: "stream", load: streamLoader(version.contentKey) },
403
476
  };
404
477
  }
405
478
  // ⚠️ A row of a kind this build no longer makes — an `agent` or a `board` written before #390
@@ -574,18 +647,25 @@ export function createBundle(deps) {
574
647
  await writer.ready;
575
648
  continue;
576
649
  }
650
+ // Nothing is written for it — not even an empty file, which is the whole point (#534). The
651
+ // manifest carries the node and says why its path is unoccupied.
652
+ if (entry.content.type === "absent")
653
+ continue;
577
654
  if (entry.content.type === "text") {
578
655
  await pushText(entry.manifest.path, await entry.content.load());
579
656
  continue;
580
657
  }
658
+ // ⚠️ No `null` branch here any more, and its removal is the repair (#560): it wrote a file
659
+ // of zero bytes for "the loader has nothing", which was true both for an attachment nobody
660
+ // ever uploaded to and for one whose object had left R2. The first is now `absent` above
661
+ // and never reaches this loop; the second throws out of `load()` and aborts the whole zip,
662
+ // which is what a backup that cannot be written is supposed to do.
663
+ //
664
+ // The loader runs BEFORE the entry is added, so a refusal leaves no half-opened file
665
+ // behind in the zip that nothing will ever terminate.
666
+ const stream = await entry.content.load();
581
667
  const file = new ZipPassThrough(entry.manifest.path);
582
668
  zip.add(file);
583
- const stream = await entry.content.load();
584
- if (stream === null) {
585
- file.push(new Uint8Array(0), true);
586
- await writer.ready;
587
- continue;
588
- }
589
669
  const reader = stream.getReader();
590
670
  for (;;) {
591
671
  const { done, value } = await reader.read();
@@ -648,7 +728,11 @@ export function createBundle(deps) {
648
728
  if (isFolder)
649
729
  folderIdByPath.set(entry.path.endsWith("/") ? entry.path : `${entry.path}/`, newId);
650
730
  let body = null;
651
- if (!isFolder) {
731
+ // ⚠️ An entry that declares an `absence` names a path with nothing at it ON PURPOSE, so the
732
+ // zip is complete rather than broken (#534). Without this the export written by this very
733
+ // build would refuse its own import — and the node it describes arrives the way it left: a
734
+ // table with a title, a place in the tree and no content.
735
+ if (!isFolder && entry.absence === undefined) {
652
736
  const found = files.get(entry.path);
653
737
  if (found === undefined) {
654
738
  throw new IntelError(400, "import_bundle_incomplete", `The manifest names a file the zip does not carry: ${entry.path}`);
@@ -933,9 +1017,14 @@ export function createBundle(deps) {
933
1017
  */
934
1018
  throw new IntelError(400, "import_kind_parked", `Bundle entry “${entry.path}” is a ${entry.kind}, and ${entry.kind}s are not part of this installation any more (anchrd/intel#385). The bundle was exported from a version that still had them; the code is on the branch parked/agents-and-board. Remove the entry from the bundle to import the rest.`);
935
1019
  }
936
- else if (entry.kind === "attachment") {
937
- const bytes = entry.body ?? new Uint8Array(0);
938
- version = versionRowFor(entry, bytes, entry.mediaType ?? "application/octet-stream", await deps.hash(bytes), null);
1020
+ else if (entry.kind === "attachment" && entry.body !== null) {
1021
+ // ⚠️ `entry.body === null` reaches here only for a manifest entry that declared an
1022
+ // absence a naked folder always has the bytes of the file it names. It used to be read
1023
+ // as `new Uint8Array(0)` and given a version of zero bytes, which is a DIFFERENT node
1024
+ // from the one that was exported: the export said "never uploaded to", and the import
1025
+ // would answer with an upload of nothing. No version is what came out of that
1026
+ // installation and no version is what goes back in (#560).
1027
+ version = versionRowFor(entry, entry.body, entry.mediaType ?? "application/octet-stream", await deps.hash(entry.body), null);
939
1028
  }
940
1029
  if (version !== null)
941
1030
  versions.push(version);
package/dist/cli/cli.js CHANGED
@@ -1,38 +1,81 @@
1
1
  import { createBuild } from "../build/build.js";
2
2
  import { createPrepare } from "../prepare/prepare.js";
3
3
  const interfaces = [
4
- { handle: "intel", functions: ["use", "admin"] },
4
+ // ⚠️ No `use`. Nothing asked it (#475): every door in Intel asks a concrete capability —
5
+ // `nodes:read`, `flows:run`, `mcp:connect` — and a coarse "may use Intel at all" beside them is a
6
+ // permission an operator sets and nothing consults. Worse than useless: somebody granted only
7
+ // `intel:use` gets the empty screen the README warns about and reads it as a defect.
8
+ // `admin` stays and is the opposite case — it is the one way into a tree that carries no grant
9
+ // yet (ADR-0004 §6), read in ten places.
10
+ { handle: "intel", functions: ["admin"] },
5
11
  // ⚠️ `nodes`, not `knowledge` — and the rename cost something, which is why it needed its own
6
12
  // ticket (#152) five months after #125 renamed everything else. This handle is only half Intel's:
7
- // `bootstrap` DECLARES it in Gate and deliberately changes no grant, so to Gate a renamed handle
8
- // is a NEW one. Every grant on the old name keeps pointing at the old name, and everybody loses
9
- // access at once, silently, until an operator hands the permissions out again.
13
+ // `bootstrap` DECLARES it in Gate and touches no grant on an interface it does not name, so to
14
+ // Gate a renamed handle is a NEW one. Every grant on the old name keeps pointing at the old name,
15
+ // and everybody loses access at once, silently, until an operator hands the permissions out again.
10
16
  //
11
17
  // It was done in the one window where that is free: the installation on anchrd.sh was being
12
18
  // rebuilt anyway (#385, anchrd/core#93) and there is no other. Doing it later would mean doing it
13
19
  // to somebody.
14
20
  //
15
- // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares, it never revokes.
16
- // It is a harmless leftover on installations that have it, and nothing in Intel asks about it any
17
- // more; removing it is an act in Gate, by hand, when somebody is sure nothing else uses it.
21
+ // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares only the handles in
22
+ // this list and never revokes a whole INTERFACE. It is a harmless leftover on installations that
23
+ // have it, and nothing in Intel asks about it any more; removing it is an act in Gate, by hand,
24
+ // when somebody is sure nothing else uses it.
18
25
  { handle: "nodes", functions: ["read", "create", "write", "share"] },
19
26
  // ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
20
- // a function nobody asks about is a permission an operator has to decide on for no reason. An
21
- // installation that already granted it keeps a harmless leftover — bootstrap declares, it does
22
- // not revoke.
27
+ // a function nobody asks about is a permission an operator has to decide on for no reason.
28
+ //
29
+ // ⚠️ Dropping a FUNCTION from a still-declared interface is not the harmless leftover the two
30
+ // blocks around this one describe (#474). `gate.set` is `PUT /api/v1/service/interfaces/:handle`,
31
+ // an upsert that REPLACES the function list, and in the same batch Gate runs `obsoleteGrants` —
32
+ // `DELETE FROM grants WHERE interface_id = ? AND function NOT IN (…)`. Gate holds that down with
33
+ // a test of its own (`service-interfaces.int.ts`, "scoped set/remove per App-Key und entfernt
34
+ // Grants gestrichener Funktionen"), and it is right to: catalog and grants are one change against
35
+ // Gate's own reasoning above `obsoleteGrants`. So an installation that granted `approve` does
36
+ // NOT keep it — the next bootstrap deletes every role assignment on it. That is why bootstrap
37
+ // reads the current list before it writes and names what it takes away.
23
38
  {
24
39
  handle: "flows",
25
40
  functions: ["read", "create", "write", "publish", "run", "share"],
26
41
  },
27
- { handle: "tools", functions: ["read", "test", "execute", "admin"] },
28
- // ⚠️ `agents:run` is deliberately NOT here any more (#390). `bootstrap` declares, it never
29
- // revokes, so an installation that has the interface keeps it as a harmless leftover but a new
30
- // one is not asked to decide about a permission nothing reads.
42
+ // ⚠️ No `admin`. Nothing asked it either (#475), and here the reason is architectural rather than
43
+ // an oversight: the Cloudflare MCP Portal owns which servers exist and who may reach each one, and
44
+ // Intel stores no tool permissions at all the catalog is a live `tools/list` with the asking
45
+ // person's token. There is no administration of tools inside Intel for the function to guard, so
46
+ // it cannot grow a reader later without that boundary moving first.
47
+ { handle: "tools", functions: ["read", "test", "execute"] },
48
+ // ⚠️ `agents:run` is deliberately NOT here any more (#390). The whole `agents` handle left the
49
+ // list, and bootstrap writes only the handles it names, so an installation that has that
50
+ // interface keeps it and its grants as a harmless leftover — but a new one is not asked to decide
51
+ // about a permission nothing reads. ⚠️ This holds because a whole INTERFACE went; a single
52
+ // function taken out of a handle that is still listed here is deleted along with its grants, see
53
+ // the block above (#474).
31
54
  // `mcp:connect` means the same thing at every MCP service of this installation. A separate name
32
55
  // for the same thing forces the operator to check, service by service, which permission carries
33
56
  // portal access.
34
57
  { handle: "mcp", functions: ["connect"] },
35
58
  ];
59
+ // What Intel used to declare and no longer does, keyed by handle for a whole interface and by
60
+ // `handle:function` for a single function.
61
+ //
62
+ // ⚠️ It exists because "surplus: approve" is not actionable on its own (#466). An operator who reads
63
+ // only the name has to decide whether somebody meant it, and the cheapest way out of that decision
64
+ // is to leave it standing — which is how `approve` outlived #73 by months. The reason is the half
65
+ // that turns the note into a decision instead of a puzzle, so a new retirement belongs in here in
66
+ // the same pull request that takes its entry out of `interfaces` above.
67
+ const retired = {
68
+ knowledge: "the whole interface became `nodes` (#152, after #125).",
69
+ agents: "the whole interface went when Agents left the surface (#390, #388).",
70
+ "flows:approve": "the approval node is gone (#73).",
71
+ "intel:use": "no door ever asked it; every one of them asks a concrete capability (#475).",
72
+ "tools:admin": "the portal owns tool administration, so Intel never had anything to guard (#475).",
73
+ };
74
+ // An unlisted surplus gets a sentence too. "no reason recorded" is an answer an operator can act on;
75
+ // an empty dash reads as an omission and sends them looking for the missing half.
76
+ function reason(key) {
77
+ return retired[key] ?? "no reason is recorded here, so read the history before removing it.";
78
+ }
36
79
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
37
80
  intel prepare Copy versioned D1 migrations to .intel/migrations
38
81
  intel bootstrap Idempotently declare Intel interfaces in Gate
@@ -67,13 +110,41 @@ export function createCli(deps) {
67
110
  return fail("GATE_URL and GATE_SERVICE_KEY must be set.");
68
111
  }
69
112
  const gate = deps.createGateInterfaces(gateUrl, serviceKey);
70
- for (const entry of interfaces)
113
+ // ⚠️ Read before writing, and refuse if the read fails. The current list is the only place the
114
+ // functions Gate is about to lose are named; `set` replaces it, and afterwards nothing can say
115
+ // which grants went with them (#474). A run that writes anyway takes permissions away and has
116
+ // nothing left to report — the old "No grants were changed." in exactly the shape that made it
117
+ // wrong. Bootstrap is idempotent, so refusing costs a second run.
118
+ let current;
119
+ try {
120
+ current = await within(gate.list());
121
+ }
122
+ catch {
123
+ return fail("Gate interfaces could not be read, so bootstrap cannot say which grants it would delete.");
124
+ }
125
+ let removed = 0;
126
+ for (const entry of interfaces) {
127
+ const declared = entry.functions;
128
+ const dropped = (current.find((row) => row.handle === entry.handle)?.functions ?? []).filter((fn) => !declared.includes(fn));
71
129
  await gate.set(entry.handle, [...entry.functions]);
72
- deps.log(`Declared ${interfaces.length} Intel interfaces in Gate. No grants were changed.`);
130
+ // Logged per interface, right after its own write returned, so a run that fails halfway still
131
+ // named every deletion it actually caused.
132
+ if (dropped.length) {
133
+ removed += dropped.length;
134
+ const names = dropped.map((fn) => `${entry.handle}:${fn}`).join(", ");
135
+ deps.log(`Removed ${names} from Gate; every grant on ${dropped.length === 1 ? "it" : "them"} was deleted with it.`);
136
+ }
137
+ }
138
+ deps.log(`Declared ${interfaces.length} Intel interfaces in Gate.`);
139
+ if (!removed)
140
+ deps.log("No declared function was dropped, so Gate deleted no grant.");
73
141
  return 0;
74
142
  }
75
143
  async function doctor() {
76
144
  const failures = [];
145
+ // A surplus hinders nothing, so it is never a `FAIL`: a doctor one knows red is a doctor nobody
146
+ // reads, and then the next real failure goes with it (#466).
147
+ const notes = [];
77
148
  const gateUrl = envValue("GATE_URL");
78
149
  const intelUrl = envValue("INTEL_URL");
79
150
  const serviceKey = envValue("GATE_SERVICE_KEY") ?? envValue("GATE_KEY");
@@ -106,17 +177,44 @@ export function createCli(deps) {
106
177
  if (gateUrl && serviceKey) {
107
178
  try {
108
179
  const current = await within(deps.createGateInterfaces(gateUrl, serviceKey).list());
180
+ let surplusFunction = false;
109
181
  for (const expected of interfaces) {
110
182
  const actual = current.find((entry) => entry.handle === expected.handle);
111
183
  const missing = expected.functions.filter((fn) => !actual?.functions.includes(fn));
112
184
  if (missing.length)
113
185
  failures.push(`Gate interface ${expected.handle} misses ${missing}`);
186
+ const declared = expected.functions;
187
+ for (const fn of actual?.functions ?? []) {
188
+ if (declared.includes(fn))
189
+ continue;
190
+ surplusFunction = true;
191
+ notes.push(`Gate carries ${expected.handle}:${fn}, which Intel does not declare — ${reason(`${expected.handle}:${fn}`)}`);
192
+ }
193
+ }
194
+ // ⚠️ The other direction, and it is the half a loop over `interfaces` can never reach: a
195
+ // handle Intel stopped declaring appears in no expectation, so it is never looked up. The
196
+ // comparison runs both ways or it does not run (#466).
197
+ const declaredHandles = interfaces.map((entry) => entry.handle);
198
+ const surplusHandles = current.filter((row) => !declaredHandles.includes(row.handle));
199
+ for (const row of surplusHandles) {
200
+ notes.push(`Gate carries the interface ${row.handle} (${row.functions.join(", ")}), which Intel does not declare — ${reason(row.handle)}`);
201
+ }
202
+ // What to do about it, once per kind — because the two kinds need opposite acts. A surplus
203
+ // FUNCTION on a still-declared handle disappears by itself on the next `bootstrap`, grants
204
+ // and all (#474); a surplus HANDLE is never written and therefore never goes away on its own.
205
+ if (surplusFunction) {
206
+ notes.push("The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.");
207
+ }
208
+ if (surplusHandles.length) {
209
+ notes.push("`bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.");
114
210
  }
115
211
  }
116
212
  catch {
117
213
  failures.push("Gate interface check failed");
118
214
  }
119
215
  }
216
+ for (const note of notes)
217
+ deps.log(`NOTE ${note}`);
120
218
  if (failures.length) {
121
219
  for (const failure of failures)
122
220
  deps.log(`FAIL ${failure}`);
@@ -1,4 +1,5 @@
1
1
  import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
2
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
4
  import { plainTitle } from "../shared/plain-title/plain-title.js";
4
5
  function invalid(detail) {
@@ -112,9 +113,22 @@ function toolStepDetail(missing) {
112
113
  }
113
114
  export function compileFlow(graph) {
114
115
  const nodes = new Map();
116
+ // ⚠️ A refusal names a node the way the canvas does — by its label — because the id appears
117
+ // nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author looking
118
+ // for a string the editor never draws, and the way to the cause led through the contract schema
119
+ // instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP as
120
+ // readily as from the canvas, and there the id is what the caller holds.
121
+ //
122
+ // ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
123
+ // it too — that one names the node the map already holds, which is the FIRST of the two. An id
124
+ // nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
125
+ const named = (id) => {
126
+ const node = nodes.get(id);
127
+ return node ? `"${node.label}" (${id})` : id;
128
+ };
115
129
  for (const node of graph.nodes) {
116
130
  if (nodes.has(node.id))
117
- invalid(`Duplicate node ID: ${node.id}`);
131
+ invalid(`Duplicate node ID: ${named(node.id)}`);
118
132
  nodes.set(node.id, node);
119
133
  }
120
134
  // ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
@@ -137,7 +151,7 @@ export function compileFlow(graph) {
137
151
  // One holder per attached node: two steps claiming the same material would make "what does
138
152
  // this step work with" answerable two ways, and the second answer would never be shown.
139
153
  if (attachedTo.has(edge.target))
140
- invalid(`Node ${edge.target} is already attached to ${attachedTo.get(edge.target)}`);
154
+ invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
141
155
  attachedTo.set(edge.target, edge.source);
142
156
  attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
143
157
  continue;
@@ -168,44 +182,44 @@ export function compileFlow(graph) {
168
182
  // Before this, a link could stand in the chain — which is how a start with an attachment once
169
183
  // began its run at the attachment (#37).
170
184
  if (layer === "link" && holderId === undefined) {
171
- invalid(`Node ${node.id} is a link and must be attached to a step`);
185
+ invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
172
186
  }
173
187
  if (layer !== "link" && holderId !== undefined) {
174
- invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
188
+ invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
175
189
  }
176
190
  if (holderId !== undefined) {
177
191
  if (parents !== 0 || children.length !== 0) {
178
- invalid(`Node ${node.id} is attached as context and cannot also be a step`);
192
+ invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
179
193
  }
180
194
  const holder = nodes.get(holderId);
181
195
  // Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
182
196
  // flow it names brings its own links, and lending it one from here would be steering another
183
197
  // flow from outside.
184
198
  if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
185
- invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
199
+ invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
186
200
  }
187
201
  continue;
188
202
  }
189
203
  if (node.kind === "trigger" && parents !== 0)
190
204
  invalid("The trigger cannot have an incoming edge");
191
205
  if (node.kind !== "trigger" && parents !== 1) {
192
- invalid(`Node ${node.id} requires exactly one incoming edge`);
206
+ invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
193
207
  }
194
208
  if (node.kind === "output" && children.length !== 0)
195
- invalid(`Output ${node.id} must be terminal`);
209
+ invalid(`Output ${named(node.id)} must be terminal`);
196
210
  if (node.kind === "condition") {
197
211
  if (children.length < 2)
198
- invalid(`Node ${node.id} requires at least two branches`);
212
+ invalid(`Node ${named(node.id)} requires at least two branches`);
199
213
  const handles = new Set(children.map((edge) => edge.sourceHandle));
200
214
  if (handles.has(null) || handles.size !== children.length) {
201
- invalid(`Node ${node.id} requires unique branch handles`);
215
+ invalid(`Node ${named(node.id)} requires unique branch handles`);
202
216
  }
203
217
  }
204
218
  else if (node.kind !== "output") {
205
219
  if (children.length !== 1)
206
- invalid(`Node ${node.id} requires exactly one outgoing edge`);
220
+ invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
207
221
  if (children[0]?.sourceHandle !== null)
208
- invalid(`Node ${node.id} cannot define a branch handle`);
222
+ invalid(`Node ${named(node.id)} cannot define a branch handle`);
209
223
  }
210
224
  }
211
225
  const visited = new Set();
@@ -1187,6 +1201,7 @@ export function createFlows(deps) {
1187
1201
  flowId: flow.id,
1188
1202
  versionId: null,
1189
1203
  nodes: [],
1204
+ invalidNodes: [],
1190
1205
  hiddenNodes: 0,
1191
1206
  servers: [],
1192
1207
  };
@@ -1194,16 +1209,31 @@ export function createFlows(deps) {
1194
1209
  const version = await requireVersion(versionId, flow.id);
1195
1210
  const referenced = graphReferences(version.graph);
1196
1211
  // ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
1197
- // What it hands back is named; the difference between what was asked for and what came back
1198
- // is a number, because a title is exactly what someone without access may not learn (#17,
1199
- // #19). Tool names are not filtered: they come from a graph this actor may already read, and
1200
- // whether the portal offers them is a question only their own token can answer.
1212
+ // What it hands back is named; a title is exactly what someone without access may not learn
1213
+ // (#17, #19). Tool names are not filtered: they come from a graph this actor may already
1214
+ // read, and whether the portal offers them is a question only their own token can answer.
1201
1215
  const reachable = await reachableNodes(actor, referenced.nodes);
1216
+ const named = new Set(reachable.map((reference) => reference.id));
1217
+ const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
1218
+ // ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
1219
+ // it" — a sentence about a permission, in front of a link that names nothing at all (#509).
1220
+ // The two suggest opposite actions and only one of them helps: with a permission one asks for
1221
+ // access, with a dead reference the step has to be replaced before the flow can run.
1222
+ //
1223
+ // ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
1224
+ // is asked without an actor because its answer does not depend on one. That is the narrowest
1225
+ // shape this distinction can have — the alternative, a lookup that is kinder than the door it
1226
+ // describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
1227
+ // deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
1228
+ // whether the tree still holds a row for it.
1229
+ const alive = new Set(await deps.repository.existingNodes(unreachable));
1230
+ const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
1202
1231
  return {
1203
1232
  flowId: flow.id,
1204
1233
  versionId: version.id,
1205
1234
  nodes: reachable,
1206
- hiddenNodes: referenced.nodes.length - reachable.length,
1235
+ invalidNodes,
1236
+ hiddenNodes: unreachable.length - invalidNodes.length,
1207
1237
  servers: referenced.servers,
1208
1238
  };
1209
1239
  },
@@ -1233,6 +1263,11 @@ export function createFlows(deps) {
1233
1263
  if (replayed)
1234
1264
  return await withShareWarnings(actor, flow, replayed, replayed.principal);
1235
1265
  }
1266
+ // ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
1267
+ // in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
1268
+ // a retry with the same key still answers with the grant its first attempt made. The long
1269
+ // form of the argument stands at the node grant in `nodes.ts`.
1270
+ requireFutureExpiry(input.expiresAt, deps.now());
1236
1271
  const timestamp = deps.now().toISOString();
1237
1272
  const grant = await deps.repository.setFlowGrant({
1238
1273
  grant: {
@@ -82,6 +82,21 @@ export interface FlowRepository {
82
82
  hidden: number;
83
83
  }>;
84
84
  nodeReferences(actor: FlowPrincipal, folderId: string): Promise<string[]>;
85
+ /**
86
+ * Which of these ids still have a row in the tree at all — archived or not, for anybody (#509).
87
+ *
88
+ * ⚠️ It takes NO actor, and that is the honest shape rather than an oversight: the answer does
89
+ * not depend on who is asking, so an `actor` parameter would suggest a narrowing that is not
90
+ * there. Its one caller has already asked `visibleNodes` and only reaches this for the ids that
91
+ * came back empty, so what it adds is a single bit about ids the caller sent us out of a graph
92
+ * they may already read.
93
+ *
94
+ * ⚠️ An ARCHIVED node counts as existing. Archiving is reversible — the row is still there and a
95
+ * restore brings the reference back — so calling it invalid would tell an author to replace a
96
+ * step that is about to work again. Only a purge takes the row (`DELETE FROM nodes`, #457), and
97
+ * only that is what "there is nothing here any more" means.
98
+ */
99
+ existingNodes(nodeIds: string[]): Promise<string[]>;
85
100
  publishedCallees(flowId: string): Promise<{
86
101
  title: string;
87
102
  calleeIds: string[];
@@ -228,6 +243,12 @@ export interface FlowDeps {
228
243
  * requirements list names it; and every tree link of every run is authorized by it, nested
229
244
  * calls included (ADR-0004 §4). A second lookup beside it is how a drawing or a message ends up
230
245
  * kinder than the door it describes — which is what #17 and #19 were sent back for.
246
+ *
247
+ * ⚠️ `existingNodes` on the repository is the ONE thing asked beside it, and it is the exception
248
+ * that shows the rule rather than a hole in it: it runs only on ids this door already refused, it
249
+ * takes no actor, and it can only ever make the answer HARSHER — "this names nothing" instead of
250
+ * "you cannot see it". Nothing about a node it does hold reaches the caller, so no drawing and no
251
+ * message becomes kinder. Adding a third would need the same three properties (#492, #509).
231
252
  */
232
253
  visibleNodes(actor: FlowActor, nodeId: string): Promise<Node | null>;
233
254
  toolSurfaceFingerprint(actor: FlowActor, server: string, allow: string[] | null): Promise<string | null>;
@@ -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({
@@ -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";
@@ -1025,6 +1026,20 @@ export function createNodes(deps) {
1025
1026
  };
1026
1027
  }
1027
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());
1028
1043
  const timestamp = deps.now().toISOString();
1029
1044
  const grant = await deps.repository.setGrant({
1030
1045
  grant: {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.24.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.20.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",