@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 +37 -7
- package/dist/adapters/db/db-flows.js +16 -0
- package/dist/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +104 -15
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +52 -17
- package/dist/flows/flows.types.d.ts +21 -0
- package/dist/intel/intel.js +29 -1
- package/dist/nodes/nodes.js +15 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/package.json +3 -3
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`
|
|
79
|
-
administrator can hand out; it hands out none of them. This
|
|
80
|
-
installation shows an empty screen to everybody including the
|
|
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
|
-
⚠️
|
|
83
|
-
|
|
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` | `
|
|
288
|
+
| `intel` | `admin` |
|
|
259
289
|
| `nodes` | `read`, `create`, `write`, `share` |
|
|
260
290
|
| `flows` | `read`, `create`, `write`, `publish`, `run`, `share` |
|
|
261
|
-
| `tools` | `read`, `test`, `execute
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/build/build.js
CHANGED
|
@@ -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;
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
// is a NEW one. Every grant on the old name keeps pointing at the old name,
|
|
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
|
|
16
|
-
// It is a harmless leftover on installations that
|
|
17
|
-
// more; removing it is an act in Gate, by hand,
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
// not
|
|
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
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/flows/flows.js
CHANGED
|
@@ -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;
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
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
|
-
|
|
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>;
|
package/dist/intel/intel.js
CHANGED
|
@@ -97,7 +97,35 @@ export function createIntel(deps) {
|
|
|
97
97
|
return context.redirect("/tools?connectError=portal_sign_in_failed");
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
-
|
|
100
|
+
// The other end of the same walk: `/auth/connect` starts it, `/auth/callback` finishes it, and
|
|
101
|
+
// a reader who lands in a white JSON page does not distinguish which of the two put them there
|
|
102
|
+
// (#444). Everything that reaches this catch has already lost its handoff — `callback` answers
|
|
103
|
+
// the paths that still know where the person wanted to go — so the destination here is a
|
|
104
|
+
// DECIDED one rather than a remembered one.
|
|
105
|
+
//
|
|
106
|
+
// ⚠️ `/tools` and not `/`: it is the only screen that reads `connectError` and turns it into a
|
|
107
|
+
// sentence (`portalAnswerOf`), and every code but the two about access lands on "the sign-in is
|
|
108
|
+
// broken" there — which is what happened. The root would take the reader somewhere that says
|
|
109
|
+
// nothing at all about the attempt they just made. Somebody who has no Intel session either is
|
|
110
|
+
// sent on to the login by the interface's own 401 handling, once, bounded by the sign-in loop
|
|
111
|
+
// guard (#118).
|
|
112
|
+
app.get("/auth/callback", async (context) => {
|
|
113
|
+
try {
|
|
114
|
+
return await browserAuth.callback(new URL(context.req.url), context.req.raw.headers);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// ⚠️ The IntelError is logged here although `app.onError` deliberately does not log one:
|
|
118
|
+
// an expected refusal explains itself through its body, and this route no longer has a
|
|
119
|
+
// body. `oauth_session_invalid` is raised for two different situations — no readable
|
|
120
|
+
// handoff at all, and a Gate login handoff that ran out — and a deployment where EVERY
|
|
121
|
+
// callback fails (a rotated session key, a clock that drifted, a cookie the browser stopped
|
|
122
|
+
// sending) looks from the outside exactly like one person with a stale tab. Without this
|
|
123
|
+
// line nothing anywhere tells the two apart.
|
|
124
|
+
reportUnexpectedError(error);
|
|
125
|
+
const code = error instanceof IntelError ? error.code : "portal_sign_in_failed";
|
|
126
|
+
return context.redirect(`/tools?connectError=${encodeURIComponent(code)}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
101
129
|
app.post("/auth/logout", () => browserAuth.logout());
|
|
102
130
|
}
|
|
103
131
|
app.route("/api/v1", createHttp({
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -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.
|
|
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.
|
|
46
|
-
"@anchrd/intel-contract": "^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",
|