@anchrd/intel-api 0.16.1 → 0.18.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
@@ -22,26 +22,203 @@ customer CLI.
22
22
  optional and only add semantic search and attachment conversion)
23
23
  - Node 22 or newer
24
24
 
25
- ## Install
25
+ ## Setting Intel up
26
+
27
+ Nine steps. Step 3 talks only to Gate and can happen at any point; the rest run in this order,
28
+ because each one needs what the one before it produced. **Step 8 is the one people skip**, and
29
+ skipping it produces an installation that looks broken and is not.
26
30
 
27
31
  ```bash
28
32
  npm install @anchrd/intel-api @anchrd/intel-ui
29
- npx intel prepare
30
- npx intel bootstrap
31
- npx intel build
32
- npx intel doctor
33
33
  ```
34
34
 
35
- Use the Cloudflare export from a customer Worker:
35
+ ### 1. Create the Cloudflare resources
36
+
37
+ Workflows are created by the deploy in step 7; everything else has to exist first.
38
+
39
+ ```bash
40
+ wrangler d1 create intel # note the printed database_id
41
+ wrangler r2 bucket create intel-content
42
+ wrangler queues create intel-indexing
43
+ wrangler queues create intel-indexing-dlq # without it an exhausted message is dropped silently
44
+ wrangler vectorize create intel-nodes --dimensions=1024 --metric=cosine # optional, see Bindings
45
+ ```
46
+
47
+ The Vectorize shape is not a free choice: 1024 dimensions and cosine are what Intel's default
48
+ multilingual Workers AI embedding model (`@cf/baai/bge-m3`) produces. Nothing in the deploy compares
49
+ the two, so an index created with other values fails later, at the first write.
50
+
51
+ ### 2. Write the Worker
52
+
53
+ The customer edge re-exports the ready-made Cloudflare shell and nothing else. The named Workflow
54
+ class has to travel with it or Wrangler cannot find the entrypoint:
36
55
 
37
56
  ```ts
38
57
  // biome-ignore lint/performance/noBarrelFile: Wrangler needs the named Workflow entrypoint.
39
58
  export { default, IntelFlowWorkflow } from "@anchrd/intel-api/cloudflare";
40
59
  ```
41
60
 
42
- `intel build` reads an optional strict `intel.json`. Customer theme files override shadcn semantic
43
- tokens; SVG logo/favicon and complete language catalogs are embedded at build time. Installed builds
44
- land in `.intel/ui`, and migrations in `.intel/migrations`.
61
+ Its Wrangler configuration declares the bindings from the table below, and points
62
+ `migrations_dir` at `.intel/migrations` the directory step 4 fills. `assets.directory` points at
63
+ `.intel/ui`, which step 5 fills.
64
+
65
+ ### 3. Declare Intel's interfaces in Gate
66
+
67
+ ```bash
68
+ GATE_URL=… GATE_SERVICE_KEY=… npx intel bootstrap
69
+ ```
70
+
71
+ ⚠️ **`bootstrap` reads its configuration from the environment of the shell it runs in**, not from
72
+ the Worker's secrets — the two are different places that happen to use the same names. Without both
73
+ values it stops with `Error: GATE_URL and GATE_SERVICE_KEY must be set.` before it does anything.
74
+
75
+ It declares five interfaces — `intel`, `nodes`, `flows`, `tools`, `mcp` — with the functions listed
76
+ under *Permission, layer one* below. It is idempotent and safe to repeat after an upgrade.
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.
81
+
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.
84
+
85
+ ### 4. Copy the migrations out of `node_modules`
86
+
87
+ ```bash
88
+ npx intel prepare
89
+ ```
90
+
91
+ This writes the versioned SQL to `.intel/migrations`. It only copies — applying them is the next
92
+ step, and nothing does it for you:
93
+
94
+ ```bash
95
+ wrangler d1 migrations apply intel --local # a local Worker
96
+ wrangler d1 migrations apply intel --remote # the deployed one
97
+ ```
98
+
99
+ Migration filenames and their order are immutable after release. Re-run `intel prepare` after every
100
+ upgrade of this package, before applying.
101
+
102
+ ### 5. Build the UI
103
+
104
+ ```bash
105
+ npx intel build
106
+ ```
107
+
108
+ `intel build` reads an optional strict `intel.json`, applies the customer branding, and writes the
109
+ static output to `.intel/ui`. See *Branding* below. No customer build output ever lives in
110
+ `node_modules`.
111
+
112
+ ### 6. Configure the Worker
113
+
114
+ Set the variables and secrets from the two tables below. `GATE_SERVICE_KEY` and
115
+ `INTEL_SESSION_SECRET` are Wrangler secrets (`wrangler secret put …`); the rest are plain vars.
116
+
117
+ For a local Worker, copy `node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars`.
118
+
119
+ ### 7. Check it, then deploy
120
+
121
+ ```bash
122
+ npx intel doctor
123
+ wrangler deploy
124
+ ```
125
+
126
+ `doctor` checks the same environment `bootstrap` uses, plus the installed packages and migrations
127
+ and the Gate interfaces. It reports every problem at once rather than the first:
128
+
129
+ ```text
130
+ Note: MCP_PORTAL_URL is unset, so the Tools area stays empty.
131
+ FAIL GATE_URL is missing
132
+ FAIL INTEL_URL is missing
133
+ FAIL GATE_SERVICE_KEY is missing
134
+ FAIL TOOL_SOURCE_ORIGINS is missing
135
+ FAIL INTEL_SESSION_SECRET must contain at least 32 bytes
136
+ ```
137
+
138
+ ⚠️ **`doctor` cannot see the Worker's secrets.** It answers about the shell it runs in, so it says
139
+ nothing about whether the deployed Worker is configured. `GET /health` on the deployed Worker
140
+ answers `{"status":"ok"}`; a Worker missing one of the five required variables answers
141
+ `500 configuration_missing` and names them.
142
+
143
+ ⚠️ **`doctor` does not check that step 4 happened.** It looks for the migrations *installed* in
144
+ `node_modules`, not for the copies in `.intel/migrations`, so it answers `OK` on a project that never
145
+ ran `intel prepare`. `wrangler deploy` does not mind an empty `migrations_dir` either, and the first
146
+ sign is a database with no tables in it. The `.intel/migrations` directory has to be checked by eye.
147
+
148
+ ### 8. Hand out permissions — both layers
149
+
150
+ This is the step that decides whether anybody sees anything, and it is entirely manual.
151
+
152
+ 1. In **Gate**, grant people the Intel capabilities from *Permission, layer one*. Without
153
+ `nodes.read` the sidebar answers `403` and says a permission is missing.
154
+ 2. In **Intel**, share a node with them. Without a grant the tree is empty — correctly, because
155
+ nothing has been shared yet.
156
+
157
+ Both are needed. Neither on its own produces a usable screen, and the symptom of missing either one
158
+ is the same shape: a view with nothing in it. See *Permission has two layers* below.
159
+
160
+ ### 9. Connect the portal, if there is one
161
+
162
+ Tools are optional. If the installation has a Cloudflare MCP Portal, set `MCP_PORTAL_URL` and add
163
+ its origin to `TOOL_SOURCE_ORIGINS`, then read *Tools come from the portal* below — two settings
164
+ outside this repository decide whether the silent sign-in can work at all.
165
+
166
+ ## Bindings
167
+
168
+ The reference Wrangler deployment binds all of these. "Required" means a request fails without it,
169
+ not that Wrangler refuses to deploy.
170
+
171
+ | Binding | Resource | Required | What its absence costs |
172
+ |---|---|---|---|
173
+ | `DB` | D1 | **yes** | Nothing works: D1 owns metadata, relationships, grants, flow and run state, audit |
174
+ | `CONTENT` | R2 | **yes** | Node content and attachments cannot be read or written |
175
+ | `INDEXING` | Queue | **yes** | Every save fails; this is the queue that builds the indexes |
176
+ | `FLOWS` | Workflow `IntelFlowWorkflow` | **yes** | Flows cannot be run |
177
+ | `ASSETS` | static assets from `.intel/ui` | for the UI | The API and MCP surface still answer; the browser application is not served at all |
178
+ | `AI` | Workers AI | no | **Two losses, and only one is obvious.** No semantic search, and no attachment conversion — an uploaded PDF or Word file is stored intact but never becomes searchable text |
179
+ | `SEARCH` | Vectorize index, 1024 dims, cosine | no | Search silently falls back to lexical only. Nothing says so, and half a search looks exactly like a whole one |
180
+
181
+ ⚠️ **Semantic search needs `AI` *and* `SEARCH`.** Either one alone leaves search lexical. If you
182
+ add them to an existing installation, run `intel reindex` afterwards — the embeddings for content
183
+ already stored are built by that pass and by nothing else.
184
+
185
+ There is no `AGENT` service binding and no `AI_GATEWAY_*` configuration. Both belonged to the agent
186
+ runtime, which is no longer part of Intel; an installation that still carries them can drop them,
187
+ including `wrangler secret delete AI_GATEWAY_READ_TOKEN`.
188
+
189
+ ## Variables and secrets
190
+
191
+ Every value below is read in **two different places** depending on the command, and mixing them up
192
+ is the most common setup failure:
193
+
194
+ - the **Worker's** environment — what the deployed Intel reads on every request;
195
+ - the **shell's** environment — what `intel bootstrap`, `intel doctor` and `intel reindex` read.
196
+
197
+ `GATE_URL`, `GATE_SERVICE_KEY`, `INTEL_URL`, `INTEL_SESSION_SECRET` and `TOOL_SOURCE_ORIGINS` are
198
+ needed in both. Setting them only as Wrangler secrets leaves the CLI blind, and setting them only in
199
+ a shell leaves the Worker answering `500 configuration_missing`.
200
+
201
+ ⚠️ `MCP_PORTAL_URL` is the eighth value in the same trap, and the one that fails quietly.
202
+ `intel doctor` reads it from the shell as well, so a portal configured only as a Wrangler var makes
203
+ `doctor` report `MCP_PORTAL_URL is unset` about an installation that is configured correctly — and
204
+ skip the origin check it would otherwise have run.
205
+
206
+ | Name | Where | Required | What it does, and what a wrong value does |
207
+ |---|---|---|---|
208
+ | `GATE_URL` | Worker + shell | **yes** | The Gate this installation authenticates against. Wrong: every request is refused and no login completes |
209
+ | `GATE_SERVICE_KEY` | Worker secret + shell | **yes** | Intel's own service credential at Gate. Wrong: every authorization call fails, so everything answers as unauthenticated. It is a secret and never appears in a response, log or tool result |
210
+ | `INTEL_URL` | Worker + shell | **yes** | Intel's own public base URL. It is also **the OAuth resource identifier**: Intel asks Gate for a token for `<INTEL_URL>/mcp`. ⚠️ A trailing-slash or scheme difference is a *different* resource. ⚠️ **Changing it later is a one-way street**: the resource is bound once on the Gate side and cannot be re-pointed — the way back is deleting the Gate service and setting it up again. Decide the hostname before the first login, not after |
211
+ | `INTEL_SESSION_SECRET` | Worker secret + shell | **yes** | Derives the key for the encrypted `HttpOnly` session cookie and for sealing each person's portal token in D1. Must be at least 32 bytes. ⚠️ **Changing it invalidates every session and makes every stored portal token unreadable** — everybody signs in again, and everybody reconnects to the portal |
212
+ | `TOOL_SOURCE_ORIGINS` | Worker + shell | **yes** | Comma-separated **exact origins** Intel is allowed to fetch tools from. ⚠️ An entry carrying a path or credentials is refused — and refused *per request*, so a bad value deploys cleanly and then answers `500` on everything |
213
+ | `MCP_PORTAL_URL` | Worker + shell (for `doctor`) | no | The portal's Streamable HTTP MCP endpoint. Unset means the Tools area stays empty and Intel says so rather than reporting an error. ⚠️ Its origin must also appear in `TOOL_SOURCE_ORIGINS`; `intel doctor` fails when it does not — but only if it can see the value, so set it in the shell too before believing the check ran |
214
+ | `ALLOW_INSECURE_OAUTH` | Worker | no | `true` permits an `http://` OAuth issuer. **Local development only.** Production issuers must be HTTPS |
215
+ | `FLOW_RUN_STALL_TIMEOUT` | Worker | no | How long a run may stand on one step before it is ended as stalled, e.g. `30 minutes`. Too short is worse than the problem: it ends runs that were merely slow |
216
+ | `INTEL_OPERATOR_TOKEN` | shell | for `reindex` | A short-lived bearer carrying the Gate capability `intel.admin`, used only by `intel reindex`. Not a Worker value and not a stored one |
217
+
218
+ Gate OAuth uses PKCE and dynamic public-client registration; browser access tokens stay inside an
219
+ encrypted `HttpOnly` cookie and are never exposed to the UI bundle or D1.
220
+
221
+ ## Branding: `intel.json`
45
222
 
46
223
  Put `intel.json` next to the customer project's `package.json`:
47
224
 
@@ -60,16 +237,105 @@ Put `intel.json` next to the customer project's `package.json`:
60
237
  The theme is plain CSS containing shadcn semantic tokens such as `--primary`, `--background`,
61
238
  `--sidebar`, and `--radius`. It is loaded after Intel's defaults, so customer tokens win without a
62
239
  component fork. Unknown config fields, missing assets, non-SVG branding, and incomplete language
63
- catalogs fail the build. A ready-to-copy starting point is included under
64
- `node_modules/@anchrd/intel-api/examples`.
240
+ catalogs fail the build rather than producing a half-branded application. A ready-to-copy starting
241
+ point is included under `node_modules/@anchrd/intel-api/examples`.
242
+
243
+ The file is optional: `intel build` without it produces the default Intel branding.
244
+
245
+ ## Permission has two layers
246
+
247
+ Access to anything in Intel is the **and** of two independent answers, and neither layer knows about
248
+ the other. This is the single most common source of "it is broken" reports, because a person missing
249
+ either layer sees roughly the same thing: a screen with nothing on it.
250
+
251
+ ### Layer one: the Gate capability
252
+
253
+ What somebody may do *at all*, granted in Gate. `intel bootstrap` declares these; an administrator
254
+ grants them.
255
+
256
+ | Interface | Functions |
257
+ |---|---|
258
+ | `intel` | `use`, `admin` |
259
+ | `nodes` | `read`, `create`, `write`, `share` |
260
+ | `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
261
+ | `tools` | `read`, `test`, `execute`, `admin` |
262
+ | `mcp` | `connect` |
65
263
 
66
- Configure customer-specific `GATE_URL`, `INTEL_URL`, and the comma-separated exact-origin allowlist
67
- `TOOL_SOURCE_ORIGINS` in the deployment environment. The Worker requires `GATE_SERVICE_KEY` and a
68
- random `INTEL_SESSION_SECRET` of at least 32 bytes as Wrangler secrets. `ALLOW_INSECURE_OAUTH=true`
69
- is only supported for explicit local development; production OAuth issuers must use HTTPS. Gate OAuth
70
- uses PKCE and dynamic public-client registration; browser access tokens stay inside an encrypted
71
- `HttpOnly` cookie and are never exposed to the UI bundle or D1. Copy
72
- `node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars` for a local customer Worker.
264
+ A missing capability answers **`403`**, and the screen says a permission is missing.
265
+
266
+ ### Layer two: the grant on the node
267
+
268
+ What somebody may reach *in this tree*, granted in Intel on a node. Four verbs — `read`, `write`,
269
+ `execute`, `share` each granted on its own; none implies another. Seeing a process is deliberately
270
+ separable from being allowed to start it.
271
+
272
+ A grant on a **folder is inherited by everything beneath it**, including nodes created after the
273
+ grant was made. That is the usual way to share a whole area.
274
+
275
+ A missing grant answers **`404`**, never `403`: Intel gives the same answer for "there is no such
276
+ node" and "not for you", so that a refusal never confirms that something exists.
277
+
278
+ ### Reading the empty screen
279
+
280
+ | What you see | Which layer |
281
+ |---|---|
282
+ | A sentence naming a missing permission | Gate — layer one |
283
+ | An empty tree, no error | Intel — nothing has been shared with this person yet |
284
+ | A node that vanished between two visits | Its grant was revoked; revocation takes effect immediately, on every path, without a new login |
285
+
286
+ A shared node appears in the recipient's navigation on its own, at the top level, without exposing
287
+ the titles of the folders above it. Nobody has to be sent a link.
288
+
289
+ ### Three things about grants that surprise people
290
+
291
+ - **A flow is not a node.** It carries no grants of its own; it inherits those of the folder it
292
+ lives in. Move a flow and you have changed who can reach it.
293
+ - **`organization` + `execute` on a folder makes that folder a library**: flows from anywhere in the
294
+ tree may then call into it. The share dialog warns before the click, because the way back is
295
+ narrow — revoking is refused with `409 folder_execute_in_use` for as long as one of those callers
296
+ still calls.
297
+ - **A grant can be complete and a flow still stop.** A run is re-authorized against the person
298
+ running it, so a flow in a shared folder that reads a document outside it stops for somebody who
299
+ cannot read that document. The answer names what was out of reach.
300
+
301
+ ## Export and import
302
+
303
+ `GET /api/v1/nodes/export` writes the whole installation as this caller may read it; the same route
304
+ under a node id exports that subtree. Import is the mirror:
305
+
306
+ ```bash
307
+ curl -X POST https://intel.example.com/api/v1/nodes/import \
308
+ -H "authorization: Bearer $TOKEN" \
309
+ -H "content-type: application/zip" \
310
+ -H "idempotency-key: $(uuidgen)" \
311
+ --data-binary @bundle.zip
312
+ ```
313
+
314
+ ⚠️ **The idempotency key travels as a header.** The body is the zip itself, so there is nowhere else
315
+ to put it. A request without the header is refused.
316
+
317
+ Only nodes the caller may read enter the bundle; exporting a folder that is out of reach answers
318
+ `404`. A fresh import answers `201`; a repeat of the same idempotency key answers `200` with the
319
+ summary of the earlier import, because this request created nothing.
320
+
321
+ ⚠️ **A bundle carries only the current state of each node. Version history does not survive
322
+ export → import.** A document with two versions comes back with one, and the earlier one is then
323
+ retrievable nowhere. Everything else survives in full — nodes per kind, table content including a
324
+ previous `redefine`, and attachments byte for byte. The loss is accepted and deliberate, but anyone
325
+ moving an installation this way has to know about it **before** they start: after the import there
326
+ is nothing to recover from.
327
+
328
+ ## Tools come from the portal
329
+
330
+ Intel stores no tool permissions. The catalog is a **live `tools/list`** made with the requesting
331
+ person's own token, never a mirrored table. Two consequences follow, and both are correct behaviour
332
+ rather than bugs:
333
+
334
+ - **Two people legitimately see different tools**, and the same person can see a different set
335
+ tomorrow. What is offered is whatever the portal answers for them at that moment.
336
+ - **A tool can disappear between defining a flow and running it.** Published flows pin immutable
337
+ MCP schema fingerprints, so a changed or vanished tool is reported by name instead of being
338
+ silently substituted.
73
339
 
74
340
  The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
75
341
  registers with Gate or Cloudflare Access, and completes a separate PKCE flow — silently, with
@@ -82,106 +348,67 @@ both, every silent sign-in is refused and the Tools area shows "No access to the
82
348
  correct behaviour for somebody outside every policy, and a misleading one for a deployment that
83
349
  simply never enabled the setting.
84
350
 
351
+ ⚠️ **Somebody who carries the Gate capability `mcp.connect` at no service at all is not sent into
352
+ that sign-in.** Gate answers that question service-wide, Intel asks it before the first redirect,
353
+ and the person stays here reading a sentence that says whom to ask — rather than landing on the
354
+ portal's own error page, outside Intel, with no way back but the browser's back button
355
+ (anchrd/intel#434). Grant `mcp.connect` alongside the Intel capabilities to everyone who should
356
+ reach Tools.
357
+
358
+ Two things that guard deliberately does not do. It **names no server**: the answer is one bit, so
359
+ somebody who may reach a different product's MCP server walks on and sees what the portal offers
360
+ them. And it **promises nothing** — this is the part worth knowing before you grant: the portal
361
+ decides on the `mcp` claim frozen into the ID token, and a running Cloudflare Access session carries
362
+ the old claim for up to 24 hours. ⚠️ **Inside that window the old dead end is still open.** Somebody
363
+ who was granted `mcp.connect` a minute ago is not refused here, walks on, and Access then decides on
364
+ the claim state that produced the error page in the first place. The guard closes the trip that is
365
+ certainly pointless; it does not close this one. The way out is the same as before — sign out of
366
+ Access and in again, so a new ID token carries the new claim. (Not measured against a running
367
+ installation; the claim lifetime is the documented one.) Against a Gate too old to answer the
368
+ question at all, nobody is refused here.
369
+
370
+ The Tools screen distinguishes four situations rather than calling all of them "no access": a
371
+ refusal that really came back, an expired connection, a sign-in that broke, and a sign-in that was
372
+ started and never answered. Only the first has no button, because only there is there nothing a
373
+ second attempt would change.
374
+
85
375
  The only tool secret Intel stores is the resulting per-user access token for its own portal
86
- endpoint — one per person, never one shared operator token — sealed with a key
87
- derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider
88
- credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
89
- to another OAuth resource.
90
-
91
- Where agents are deployed, bind the agent runtime Worker as the service `AGENT` the reference
92
- deployment does, as `{ "binding": "AGENT", "service": "intel-agent" }`. The browser never talks to
93
- that Worker: it asks Intel under `/api/v1/agents/:agentId/*`, and Intel forwards the call over the
94
- binding with the Gate token of the person who asked, after checking `agents/run` and the agent's own
95
- resource ACL. So the runtime needs no second hostname, no CORS and no token in the browser bundle,
96
- and the page stays on one origin with one session cookie. The binding is optional: without it those
97
- routes answer `503 agent_runtime_not_configured` and nothing else changes. Deploy `@anchrd/intel-agent`
98
- before an Intel deployment that declares it — Wrangler refuses a binding to a service that does not
99
- exist yet.
100
-
101
- ### What an agent costs, and what a model costs
102
-
103
- Two figures on the agent screen come from Cloudflare rather than from a table in this repository:
104
- what one agent has actually spent, per run and over the last 7 and 30 days, and what each offered
105
- model charges per million tokens. Both are read by Intel and never by the browser, because reading
106
- them needs an account credential and a credential does not belong in a single-page app.
107
-
108
- Set `AI_GATEWAY_ACCOUNT_ID` and `AI_GATEWAY_ID` — the same account and gateway the agent runtime
109
- uses — and add a Wrangler secret:
110
-
111
- ```sh
112
- wrangler secret put AI_GATEWAY_READ_TOKEN # Cloudflare API token: "AI Gateway: Read" + "Workers AI: Read"
376
+ endpoint — one per person, never one shared operator token — sealed with a key derived from
377
+ `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider credentials stay
378
+ with the portal and never reach Intel.
379
+
380
+ Intel unseals that token just in time and renews it shortly before it expires. **"Expired" above is
381
+ what a failed renewal looks like**: a token that cannot be renewed is dropped rather than kept and
382
+ retried with, the browser signs in again silently, and an MCP client repeats its own authorization.
383
+ Two different causes end up there a portal that issued no refresh token at all, which is a
384
+ configuration problem, and a refresh that was rejected, which is not and Intel logs which of the
385
+ two it was, because the person only ever sees "sign in again". The Intel audience token is never forwarded to another OAuth
386
+ resource. Durable Flow state contains no browser, Gate, or provider credentials.
387
+
388
+ ## The `intel` CLI
389
+
390
+ ```text
391
+ intel prepare Copy versioned D1 migrations to .intel/migrations
392
+ intel bootstrap Idempotently declare Intel interfaces in Gate
393
+ intel build Apply intel.json and atomically build the customer UI
394
+ intel doctor Check packages, environment, migrations, UI, and Gate interfaces
395
+ intel reindex Request a full authorized search-index rebuild
113
396
  ```
114
397
 
115
- ⚠️ **This is not the runtime's `AI_GATEWAY_TOKEN`.** That one carries `AI Gateway: Run` and buys
116
- inference; this one only reads. Cloudflare cannot scope `AI Gateway: Read` to a single gateway — it
117
- is account-wide — which is worth knowing when the token is created and is not a reason against it.
118
-
119
- All three are optional. Without them the cost view says *"this installation has no AI Gateway read
120
- token"* and the model figures come off a built-in table that the interface marks as possibly out of
121
- date. Neither is an error state: an installation that gives Intel no Cloudflare credential is a
122
- supported installation. What is never done is show a zero — a missing figure and "cost nothing" are
123
- different answers, and only one of them is about money.
124
-
125
- The attribution comes from the agent runtime, which stamps `agentId` and `runId` into
126
- `cf-aig-metadata` on every model call. Runs made before that stamp existed appear with their token
127
- counts and without a cost, and say so.
128
-
129
- ### Creating an agent creates its Gate application
130
-
131
- An agent acts as its own machine principal, so creating an agent node also creates the Gate
132
- Application it runs as, and archiving the node switches that Application off (`disabledAt`) rather
133
- than deleting it. Restoring the node switches it back on — the same principal, with the same ID and
134
- the same grants, which is the whole reason it is disabled rather than deleted.
135
-
136
- Both calls are made with **the bearer of the person asking**, not with `GATE_SERVICE_KEY`. Creating
137
- and switching a machine principal is administrative work in Gate: the caller needs the applications
138
- permission there on top of `knowledge:create` in Intel, and Gate audits the act under their name. A
139
- caller without it is refused with `agent_application_forbidden`, and a Gate that does not answer
140
- with `agent_application_unavailable` — in which case **no agent node is created at all**. There is
141
- deliberately no half-created agent to repair later; the request is simply repeated.
142
-
143
- **No response in Intel carries a credential.** The Application key Gate issues once, in plain text,
144
- is handed to the agent runtime over the `AGENT` service binding inside the same request, and the
145
- runtime encrypts it into that agent's Durable Object. Intel writes it to no table, no R2 object, no
146
- audit event, no log and no response body; the browser never sees it, and neither does an MCP client.
147
- The agent can run the moment it exists — there is no secret to set and no terminal to open.
148
-
149
- The handover carries the caller's own bearer plus `AGENT_HANDOVER_SECRET`, the one value Intel and
150
- the agent runtime share. The runtime cannot check Intel's resource ACLs, so that secret is what says
151
- the key came through Intel rather than off the open internet; without it on both Workers, creating an
152
- agent refuses by name.
153
-
154
- The handover happens **before** the node row, for the same reason the Gate call does: a runtime that
155
- will not take the key leaves no agent behind, and the freshly minted Application is switched off
156
- again. There is deliberately no half-created agent to repair later; the request is simply repeated.
157
-
158
- What still stays manual is the reach: grant the application its Gate roles and the resource grants
159
- on the Intel nodes it works with. That is what makes the agent's reach its own.
160
-
161
- `POST /api/v1/nodes/agents/<id>/rotate-key` (and the `agent_rotate_key` MCP tool) replaces the key:
162
- Intel asks Gate for a new one and hands it to the runtime the same way, the previous key stops
163
- working at once, and the answer names the principal and the moment rather than the key. It needs
164
- `knowledge:write` in Intel plus write access to the agent node, and the applications permission in
165
- Gate. It is the repair for a run that fails with `agent_key_missing`.
166
-
167
- **A written schedule is an armed one.** The agent runtime sets its alarm only when something tells
168
- it to look, so Intel calls `POST /agents/<id>/schedules/sync` over the same service binding after
169
- every definition write — creating an agent with schedules, and every save. That call goes down the
170
- handover route with the caller's own bearer beside `AGENT_HANDOVER_SECRET`, because arming an agent
171
- belongs to editing one and not to driving one. A save whose arming fails keeps the definition it
172
- wrote and answers `agent_schedules_not_armed`: the version is current, and saving again arms it.
173
-
174
- Intel keeps `applicationId` beside the node and returns it on every agent read. It names the
175
- principal without authenticating it, which is why it may be stored and shown while the key may not.
176
- An agent whose `applicationId` is `null` has no principal — imported, restored from a bundle, or
177
- created before this was automatic — and archiving it touches Gate not at all.
178
-
179
- The reference Wrangler deployment binds `DB`, `CONTENT`, `INDEXING`, `AI`, `SEARCH`, `FLOWS`, and
180
- `ASSETS`. Create `SEARCH` as a 1024-dimension cosine Vectorize index for the default multilingual
181
- Workers AI `@cf/baai/bge-m3` embedding adapter. `FLOWS` targets the exported
182
- `IntelFlowWorkflow` class. The portal speaks standard Streamable HTTP MCP; Intel caches schemas and
183
- unseals the caller's portal token from `portal_tokens` just in time, refreshing it shortly before
184
- use. Durable Flow state contains no browser, Gate, or provider credentials.
398
+ All five run in the customer project directory — the one holding `package.json`, `intel.json` and
399
+ `node_modules` and read their configuration from that shell's environment.
400
+
401
+ `intel reindex` needs `INTEL_URL` and a short-lived `INTEL_OPERATOR_TOKEN`. It asks the running
402
+ installation to rebuild the derived indexes from D1 and R2 and returns as soon as the request is
403
+ accepted; the work happens on the indexing queue. Reach for it after adding `AI` and `SEARCH` to an
404
+ installation that ran without them, and after restoring an emptied Vectorize index.
405
+
406
+ ## Writing against the API directly
407
+
408
+ Everything the browser does is available over HTTP and over the Intel MCP surface, against the same
409
+ application services. If you write your own client, read
410
+ [`@anchrd/intel-contract`](https://www.npmjs.com/package/@anchrd/intel-contract) first it carries
411
+ the schemas, and the handful of things that reliably cost a first-time caller an afternoon.
185
412
 
186
413
  ## Related packages
187
414
 
@@ -114,10 +114,19 @@ export function createFlowRepository(deps) {
114
114
  // An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
115
115
  // `IS ?` would collapse the two, so the two cases are separate SQL rather than one binding that
116
116
  // silently means both.
117
+ //
118
+ // ⚠️ The root is "the top of every region this actor may read", exactly as it is for nodes in
119
+ // `db.ts` (#429) — a flow whose folder the reader may not open would otherwise sit in a level
120
+ // nothing can ever open. `flowInSubtree` is what puts such a row in reach at all (its owner, or
121
+ // `intel/admin`), and this only decides which level it appears in; it can add no flow that
122
+ // predicate has not already allowed.
117
123
  const scopeOf = (parentId) => parentId === undefined
118
124
  ? { clause: "", bindings: [] }
119
125
  : parentId === null
120
- ? { clause: "AND flow.parent_id IS NULL", bindings: [] }
126
+ ? {
127
+ clause: "AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM allowed))",
128
+ bindings: [],
129
+ }
121
130
  : { clause: "AND flow.parent_id = ?", bindings: [parentId] };
122
131
  /**
123
132
  * One statement for the list the sidebar reads and for the bounded read the relation graph makes,
@@ -109,6 +109,28 @@ export function createNodeRepository(deps) {
109
109
  const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
110
110
  const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
111
111
  const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
112
+ /**
113
+ * Which rows belong to the level being asked for.
114
+ *
115
+ * ⚠️ At the ROOT this is not `parent_id IS NULL` but "the top of every region this actor may
116
+ * read" (#429). A grant on a nested folder has no entry point otherwise: the way to it leads
117
+ * through a folder the recipient may not see, so the level it would appear in can never be
118
+ * opened, and the share stays usable only for whoever passes the URL around by hand. It is the
119
+ * same seed `listVisibleSubtree` already starts a whole-installation export from — written once
120
+ * more rather than differently.
121
+ *
122
+ * ⚠️ It says nothing about the way there. A row carries its own id and its own title, both of
123
+ * which `getVisible` already answers to this actor; the ancestors it hangs under are never read,
124
+ * named or counted, so the title of an unreadable parent folder stays where it was.
125
+ *
126
+ * ⚠️ Both `?` take the SAME value, and the second is not redundant: SQLite binds by position, so
127
+ * asking "was the root asked for" needs its own placeholder rather than a second reading of the
128
+ * first.
129
+ */
130
+ const levelPredicate = `(
131
+ n.parent_id IS ?
132
+ OR (? IS NULL AND n.parent_id NOT IN (SELECT id FROM allowed))
133
+ )`;
112
134
  /**
113
135
  * One statement for the level the tree reads and for the bounded read the relation graph makes,
114
136
  * so the visibility predicate cannot drift between them.
@@ -128,7 +150,7 @@ export function createNodeRepository(deps) {
128
150
  ) AS has_children
129
151
  FROM nodes n
130
152
  JOIN allowed ON allowed.id = n.id
131
- WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
153
+ WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL)
132
154
  ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
133
155
  /**
134
156
  * Everything archived that this actor may see, wherever it sits (#113).
@@ -150,7 +172,9 @@ export function createNodeRepository(deps) {
150
172
  async listVisible(actor, input) {
151
173
  const result = await deps.db
152
174
  .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
153
- .bind(...readBindings(actor), ...(input.archivedOnly ? [] : [input.parentId, input.includeArchived ? 1 : 0]))
175
+ .bind(...readBindings(actor), ...(input.archivedOnly
176
+ ? []
177
+ : [input.parentId, input.parentId, input.includeArchived ? 1 : 0]))
154
178
  .all();
155
179
  const rows = result.results ?? [];
156
180
  return {
@@ -161,7 +185,7 @@ export function createNodeRepository(deps) {
161
185
  async listVisibleBounded(actor, input) {
162
186
  const result = await deps.db
163
187
  .prepare(visibleChildren(true))
164
- .bind(...readBindings(actor), input.parentId, 0, input.limit)
188
+ .bind(...readBindings(actor), input.parentId, input.parentId, 0, input.limit)
165
189
  .all();
166
190
  const rows = result.results ?? [];
167
191
  return { items: rows.map(mapNode), total: rows[0]?.total ?? 0 };
@@ -417,16 +417,31 @@ export function createFlows(deps) {
417
417
  // The flow may have been built by someone with wider portal access. Naming the missing tools
418
418
  // before the first step beats failing halfway through with a portal error the user cannot
419
419
  // place — and the portal is where they can do something about it.
420
- const missing = await deps.unavailableTools(actor, graphReferences(version.graph).tools);
421
- if (missing.length) {
420
+ //
421
+ // ⚠️ ONE entry per missing TOOL, and the unit is the decision (#435). A problem list is a
422
+ // count, so the number of entries has to be the number of things left to do — and what a
423
+ // person does about a missing tool is grant it once in the portal, whether one step reaches
424
+ // for it or six. `graphReferences` already folds the graph down to distinct tool names, which
425
+ // is why a tool hanging off several steps arrives here once. Deliberately NOT one entry per
426
+ // step: the same portal click would then be listed several times, and the reader would go
427
+ // looking for several causes.
428
+ //
429
+ // The one consequence to know about: `start` throws the FIRST entry, so it now names the first
430
+ // missing tool rather than all of them — the same as it has always done for several
431
+ // unpublished sub-flows below. `validate` is what lists every reason at once, and it does.
432
+ for (const tool of await deps.unavailableTools(actor, graphReferences(version.graph).tools)) {
422
433
  problems.push({
423
434
  status: 403,
424
435
  code: "flow_tools_unavailable",
425
- detail: toolStepDetail(missing),
436
+ detail: toolStepDetail([tool]),
426
437
  });
427
438
  }
428
439
  try {
429
- await requireNodeAuthorized(actor, nodeFor(version, first), version.graph);
440
+ // ⚠️ Tree links only. The loop above has already asked about every tool in the graph — the
441
+ // first step's included — so the tool half of `requireNodeAuthorized` would answer a second
442
+ // time about the same names, which is the duplicate #435 reported. Every place that actually
443
+ // hands a step over still calls the whole of `requireNodeAuthorized`.
444
+ await requireTreeLinksAuthorized(actor, nodeFor(version, first), version.graph);
430
445
  }
431
446
  catch (error) {
432
447
  if (!(error instanceof IntelError))
@@ -543,26 +558,43 @@ export function createFlows(deps) {
543
558
  // `context` edge now, and a run never stands on one — so asking "is this node a tree link"
544
559
  // would ask about a node the run can no longer reach, and every check here would silently pass.
545
560
  async function requireNodeAuthorized(actor, node, graph) {
546
- if (!node)
547
- return;
548
- const attached = graph.edges
561
+ await requireTreeLinksAuthorized(actor, node, graph);
562
+ await requireToolsAuthorized(actor, node, graph);
563
+ }
564
+ // The material of one step: what hangs off it on a `context` edge, as nodes.
565
+ function attachedNodes(node, graph) {
566
+ return graph.edges
549
567
  .filter((edge) => edge.kind === "context" && edge.source === node.id)
550
568
  .flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
569
+ }
570
+ // ⚠️ The two halves are separate functions only because `collectRunProblems` needs the first one
571
+ // without the second (#435): every tool of the whole graph is already asked about there, so
572
+ // asking again for the first step reported the same tool twice. `requireNodeAuthorized` above is
573
+ // what every execution boundary calls, and it is still both halves — splitting the CHECK would
574
+ // be the drift this file spends a page warning about; splitting the FUNCTION is not.
575
+ async function requireTreeLinksAuthorized(actor, node, graph) {
576
+ if (!node)
577
+ return;
578
+ const attached = attachedNodes(node, graph);
551
579
  const wanted = [...new Set(treeLinkNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
552
- if (wanted.length) {
553
- const reachable = await reachableNodes(actor, wanted);
554
- if (reachable.length !== wanted.length) {
555
- throw new IntelError(403, "flow_node_forbidden", treeLinkStepDetail(node.label, wanted.length - reachable.length));
556
- }
580
+ if (!wanted.length)
581
+ return;
582
+ const reachable = await reachableNodes(actor, wanted);
583
+ if (reachable.length !== wanted.length) {
584
+ throw new IntelError(403, "flow_node_forbidden", treeLinkStepDetail(node.label, wanted.length - reachable.length));
557
585
  }
558
- const toolNames = attached
586
+ }
587
+ async function requireToolsAuthorized(actor, node, graph) {
588
+ if (!node)
589
+ return;
590
+ const toolNames = attachedNodes(node, graph)
559
591
  .filter((candidate) => candidate.kind === "tool")
560
592
  .map((candidate) => candidate.configuration.toolName);
561
- if (toolNames.length) {
562
- const missing = await deps.unavailableTools(actor, [...new Set(toolNames)]);
563
- if (missing.length) {
564
- throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
565
- }
593
+ if (!toolNames.length)
594
+ return;
595
+ const missing = await deps.unavailableTools(actor, [...new Set(toolNames)]);
596
+ if (missing.length) {
597
+ throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
566
598
  }
567
599
  }
568
600
  /**
@@ -50,7 +50,32 @@ export function createIntel(deps) {
50
50
  if (!authorization) {
51
51
  return context.redirect(`/auth/login?returnTo=${connectReturnTo}`);
52
52
  }
53
- if (!authorization.can("tools", "read")) {
53
+ // ⚠️ Two questions, and both are answered BEFORE the first redirect leaves Intel (#434).
54
+ // `tools:read` is Intel's own door. `mcpPortalRefused()` is Gate's answer to a question Intel
55
+ // cannot ask itself: does this person carry `mcp.connect` at ANY service? Measured against the
56
+ // running installation, somebody without it was sent to `mcp.anchrd.sh/servers-callback` and
57
+ // landed on a white page saying "No allowed servers available, check your Zero trust policies"
58
+ // — a sentence written for an administrator, on a foreign domain, with not one clickable
59
+ // element on it.
60
+ //
61
+ // ⚠️ The helper, never the raw `mcpConnectAnywhere` beside it. The field has THREE states, and
62
+ // `undefined` means "this Gate is older than the question" (anchrd/gate#277), not "nowhere".
63
+ // `if (!authorization.mcpConnectAnywhere)` passes `tsc --noEmit` and `biome check` alike and
64
+ // would turn every reader of such an installation away without a word — the difference is a
65
+ // value, not a type, so nothing but this line prevents it (anchrd/gate#288).
66
+ //
67
+ // ⚠️ And it is deliberately NOT `can("mcp", "connect")`, which is what this guard was first
68
+ // built as: Gate trims `rules` to the ASKING service (GATE-64, least privilege), so that asks
69
+ // `intel.mcp.connect`. The portal is a door onto every server — somebody carrying
70
+ // `dwh.mcp.connect` reaches it and sees the dwh tools, and that spelling would have locked
71
+ // them out. The bit knows no service names, on purpose; it is one bit and carries no more.
72
+ //
73
+ // ⚠️ It spares the certainly wasted trip and promises no successful one: the portal decides
74
+ // on the `mcp` claim frozen into the ID token, and a running Access session carries the old
75
+ // claim for up to 24 hours. Somebody granted a minute ago is NOT refused here, walks on, and
76
+ // meets Access deciding on the very claim state this ticket is about — inside that window the
77
+ // old dead end is still open, and this guard does not close it. It closes the certain one.
78
+ if (!authorization.can("tools", "read") || authorization.mcpPortalRefused()) {
54
79
  return context.redirect("/tools?connectError=permission_required");
55
80
  }
56
81
  try {
@@ -219,6 +219,7 @@ export interface BoundedChildren {
219
219
  export interface NodeService {
220
220
  list(actor: Actor, input: ListNodesInput): Promise<{
221
221
  items: Node[];
222
+ withChildren: string[];
222
223
  }>;
223
224
  /**
224
225
  * One level of the tree as this actor may see it, bounded: at most `limit` current children, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.16.1",
3
+ "version": "0.18.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.7.0",
46
- "@anchrd/intel-contract": "^0.14.0",
45
+ "@anchrd/gate-sdk": "^0.15.0",
46
+ "@anchrd/intel-contract": "^0.15.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",