@anchrd/intel-api 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/db/db.js +14 -2
- package/dist/adapters/openid/openid.js +17 -3
- package/dist/auth/auth.js +10 -1
- package/dist/flows/flows.js +45 -3
- package/dist/flows/flows.types.d.ts +2 -0
- package/dist/http/http.js +4 -0
- package/dist/intel/intel.js +5 -0
- package/dist/knowledge/knowledge.js +1 -1
- package/dist/knowledge/knowledge.types.d.ts +4 -1
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/package.json +2 -2
package/dist/adapters/db/db.js
CHANGED
|
@@ -89,7 +89,15 @@ export function createKnowledgeRepository(deps) {
|
|
|
89
89
|
* may see and never before them, and `COUNT(*) OVER ()` counts those same rows alone (#30).
|
|
90
90
|
*/
|
|
91
91
|
const visibleChildren = (bounded) => `${visibleCte}
|
|
92
|
-
SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""}
|
|
92
|
+
SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""},
|
|
93
|
+
-- Whether this row has children THIS actor may see (#59), asked of the same allowed set one
|
|
94
|
+
-- level down. A second rule written here would drift from the one above it, and the drift
|
|
95
|
+
-- would show as a chevron that opens onto nothing.
|
|
96
|
+
EXISTS (
|
|
97
|
+
SELECT 1 FROM knowledge_nodes child
|
|
98
|
+
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
99
|
+
WHERE child.parent_id = n.id AND child.archived_at IS NULL
|
|
100
|
+
) AS has_children
|
|
93
101
|
FROM knowledge_nodes n
|
|
94
102
|
JOIN allowed ON allowed.id = n.id
|
|
95
103
|
WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
|
|
@@ -100,7 +108,11 @@ export function createKnowledgeRepository(deps) {
|
|
|
100
108
|
.prepare(visibleChildren(false))
|
|
101
109
|
.bind(...readBindings(actor), input.parentId, input.includeArchived ? 1 : 0)
|
|
102
110
|
.all();
|
|
103
|
-
|
|
111
|
+
const rows = result.results ?? [];
|
|
112
|
+
return {
|
|
113
|
+
items: rows.map(mapNode),
|
|
114
|
+
withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
|
|
115
|
+
};
|
|
104
116
|
},
|
|
105
117
|
async listVisibleBounded(actor, input) {
|
|
106
118
|
const result = await deps.db
|
|
@@ -59,11 +59,25 @@ export function createOpenId(deps) {
|
|
|
59
59
|
...(insecure ? { execute: [client.allowInsecureRequests] } : {}),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
// openid-client resolves issuer metadata via OIDC discovery unless told otherwise. Gate serves
|
|
63
|
+
// that document, but a plain OAuth 2.0 authorization server — the MCP portal on its custom
|
|
64
|
+
// domain is one — publishes only RFC 8414's oauth-authorization-server path and answers the OIDC
|
|
65
|
+
// one with a 404, which surfaced as a bare 500 on /auth/connect (#93). The fallback repeats the
|
|
66
|
+
// operation once with the OAuth document; when both fail, the second error is thrown because it
|
|
67
|
+
// belongs to the attempt that got further for the issuer that needed the fallback at all.
|
|
68
|
+
async function withAlgorithmFallback(run) {
|
|
69
|
+
try {
|
|
70
|
+
return await run();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return await run("oauth2");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
62
76
|
async function discover(issuer, clientId) {
|
|
63
77
|
const key = `${issuer}#${clientId}`;
|
|
64
78
|
let configuration = configurations.get(key);
|
|
65
79
|
if (!configuration) {
|
|
66
|
-
configuration = client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), options(issuer));
|
|
80
|
+
configuration = withAlgorithmFallback((algorithm) => client.discovery(new URL(issuer), clientId, { token_endpoint_auth_method: "none" }, client.None(), { ...options(issuer), ...(algorithm ? { algorithm } : {}) }));
|
|
67
81
|
configurations.set(key, configuration);
|
|
68
82
|
void configuration.catch(() => configurations.delete(key));
|
|
69
83
|
}
|
|
@@ -118,7 +132,7 @@ export function createOpenId(deps) {
|
|
|
118
132
|
throw new Error("MCP resource did not publish valid OAuth metadata");
|
|
119
133
|
},
|
|
120
134
|
async register(input) {
|
|
121
|
-
const configuration = await client.dynamicClientRegistration(new URL(input.issuer), {
|
|
135
|
+
const configuration = await withAlgorithmFallback((algorithm) => client.dynamicClientRegistration(new URL(input.issuer), {
|
|
122
136
|
client_name: input.clientName,
|
|
123
137
|
redirect_uris: [input.redirectUri],
|
|
124
138
|
response_types: ["code"],
|
|
@@ -128,7 +142,7 @@ export function createOpenId(deps) {
|
|
|
128
142
|
: { scope: "openid profile email offline_access" }),
|
|
129
143
|
token_endpoint_auth_method: "none",
|
|
130
144
|
...(isCloudflareAccess(input.issuer) ? { resource: input.resource } : {}),
|
|
131
|
-
}, client.None(), options(input.issuer));
|
|
145
|
+
}, client.None(), { ...options(input.issuer), ...(algorithm ? { algorithm } : {}) }));
|
|
132
146
|
return configuration.clientMetadata().client_id;
|
|
133
147
|
},
|
|
134
148
|
async authorizationUrl(input) {
|
package/dist/auth/auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { calculatePKCECodeChallenge, randomPKCECodeVerifier, randomState } from "openid-client";
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
|
+
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
3
4
|
import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
|
|
4
5
|
const cookieName = "intel_session";
|
|
5
6
|
// What an authorization server answers when `prompt=none` would have worked, but only with somebody
|
|
@@ -54,11 +55,19 @@ export function createBrowserAuth(deps) {
|
|
|
54
55
|
const stored = await deps.clients.get(registrationKey, redirectUri);
|
|
55
56
|
if (stored)
|
|
56
57
|
return stored;
|
|
57
|
-
|
|
58
|
+
// A refused registration becomes a named state instead of a bare 500 (#93). The conversion
|
|
59
|
+
// must not eat the reason: an IntelError is an expected refusal nobody logs, so the library's
|
|
60
|
+
// own exception is written here — it is the only place that still holds it.
|
|
61
|
+
const registered = await deps.oauth
|
|
62
|
+
.register({
|
|
58
63
|
issuer,
|
|
59
64
|
redirectUri,
|
|
60
65
|
clientName: "Intel",
|
|
61
66
|
resource,
|
|
67
|
+
})
|
|
68
|
+
.catch((error) => {
|
|
69
|
+
reportUnexpectedError(error);
|
|
70
|
+
throw new IntelError(502, "client_registration_failed", "The authorization server refused the client registration");
|
|
62
71
|
});
|
|
63
72
|
return await deps.clients.put({
|
|
64
73
|
issuer: registrationKey,
|
package/dist/flows/flows.js
CHANGED
|
@@ -280,6 +280,46 @@ export function createFlows(deps) {
|
|
|
280
280
|
// library folder carries `execute` for everyone and nothing else (ADR-0004 §2/§3), so insisting on
|
|
281
281
|
// `read` here would turn every library flow into a 404 for exactly the people it exists for — and
|
|
282
282
|
// the call rule in section 3 would have nothing left to permit.
|
|
283
|
+
/**
|
|
284
|
+
* Which of these flows call another one that THIS actor may also see (#59).
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ Per reader, and that is the point: a flow whose only call is hidden from this person has
|
|
287
|
+
* nothing to unfold, so a chevron there would promise what opening cannot deliver. `listCalls`
|
|
288
|
+
* filters the same way — asking the same question here, rather than reading the graph and hoping,
|
|
289
|
+
* is the only way the two cannot drift apart.
|
|
290
|
+
*
|
|
291
|
+
* ⚠️ Two reads for the whole level, never one per flow (#30): the versions come back together and
|
|
292
|
+
* the callees go out together.
|
|
293
|
+
*/
|
|
294
|
+
async function callableCallers(actor, flows) {
|
|
295
|
+
const versionOf = new Map();
|
|
296
|
+
for (const flow of flows) {
|
|
297
|
+
const versionId = flow.currentVersionId ?? flow.publishedVersionId;
|
|
298
|
+
if (versionId)
|
|
299
|
+
versionOf.set(flow.id, versionId);
|
|
300
|
+
}
|
|
301
|
+
if (versionOf.size === 0)
|
|
302
|
+
return [];
|
|
303
|
+
const versions = new Map((await deps.repository.getVersions([...new Set(versionOf.values())])).map((version) => [
|
|
304
|
+
version.id,
|
|
305
|
+
version,
|
|
306
|
+
]));
|
|
307
|
+
const wanted = new Map();
|
|
308
|
+
for (const [flowId, versionId] of versionOf) {
|
|
309
|
+
const version = versions.get(versionId);
|
|
310
|
+
if (version)
|
|
311
|
+
wanted.set(flowId, calleeIds(version.graph));
|
|
312
|
+
}
|
|
313
|
+
const everyCallee = [...new Set([...wanted.values()].flat())];
|
|
314
|
+
if (everyCallee.length === 0)
|
|
315
|
+
return [];
|
|
316
|
+
const reachable = new Set((await deps.repository.listCallable(actor, everyCallee))
|
|
317
|
+
.filter((callee) => !callee.archivedAt)
|
|
318
|
+
.map((callee) => callee.id));
|
|
319
|
+
return [...wanted.entries()]
|
|
320
|
+
.filter(([, callees]) => callees.some((callee) => reachable.has(callee)))
|
|
321
|
+
.map(([flowId]) => flowId);
|
|
322
|
+
}
|
|
283
323
|
async function requireRunnableFlow(actor, flowId) {
|
|
284
324
|
const flow = await deps.repository.getCallable(actor, flowId);
|
|
285
325
|
if (!flow || flow.archivedAt)
|
|
@@ -720,7 +760,8 @@ export function createFlows(deps) {
|
|
|
720
760
|
}
|
|
721
761
|
return {
|
|
722
762
|
async list(actor, input = {}) {
|
|
723
|
-
|
|
763
|
+
const items = await deps.repository.listVisible(actor, input);
|
|
764
|
+
return { items, withCalls: await callableCallers(actor, items) };
|
|
724
765
|
},
|
|
725
766
|
async get(actor, flowId) {
|
|
726
767
|
const flow = await requireFlow(actor, flowId);
|
|
@@ -885,7 +926,7 @@ export function createFlows(deps) {
|
|
|
885
926
|
const flow = await requireFlow(actor, flowId);
|
|
886
927
|
const versionId = flow.currentVersionId ?? flow.publishedVersionId;
|
|
887
928
|
if (!versionId)
|
|
888
|
-
return { items: [] };
|
|
929
|
+
return { items: [], withCalls: [] };
|
|
889
930
|
const version = await requireVersion(versionId, flow.id);
|
|
890
931
|
const wanted = calleeIds(version.graph);
|
|
891
932
|
// One read for every callee rather than one per callee (#30). This is what the sidebar asks
|
|
@@ -901,7 +942,8 @@ export function createFlows(deps) {
|
|
|
901
942
|
if (callee && !callee.archivedAt)
|
|
902
943
|
items.push(callee);
|
|
903
944
|
}
|
|
904
|
-
|
|
945
|
+
// Ein aufgerufener Flow ruft selbst welche: dieselbe Frage, eine Ebene tiefer (#59).
|
|
946
|
+
return { items, withCalls: await callableCallers(actor, items) };
|
|
905
947
|
},
|
|
906
948
|
// "What this flow needs", straight out of the graph: no arithmetic, nothing that can go stale,
|
|
907
949
|
// and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
|
|
@@ -157,10 +157,12 @@ export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
|
|
|
157
157
|
export interface FlowService {
|
|
158
158
|
list(actor: FlowActor, input?: ListFlowsInput): Promise<{
|
|
159
159
|
items: Flow[];
|
|
160
|
+
withCalls: string[];
|
|
160
161
|
}>;
|
|
161
162
|
get(actor: FlowActor, flowId: string): Promise<FlowDocument>;
|
|
162
163
|
listCalls(actor: FlowActor, flowId: string): Promise<{
|
|
163
164
|
items: Flow[];
|
|
165
|
+
withCalls: string[];
|
|
164
166
|
}>;
|
|
165
167
|
validate(actor: FlowActor, flowId: string): Promise<FlowValidation>;
|
|
166
168
|
relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
|
package/dist/http/http.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
|
|
5
5
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
6
6
|
import { problemDetails as problem } from "../shared/problem-details/problem-details.js";
|
|
7
|
+
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
7
8
|
// A query string is a door to the outside like a body is, so what arrives through it is closed
|
|
8
9
|
// rather than tolerated: `z.strictObject` refuses an unknown field, and this is that refusal for the
|
|
9
10
|
// half of the input Zod never sees. A silently ignored parameter is how a caller believes it asked
|
|
@@ -64,6 +65,9 @@ export function createHttp(deps) {
|
|
|
64
65
|
if (error instanceof z.ZodError) {
|
|
65
66
|
return context.json(problem(400, "invalid_request", "Request validation failed", z.prettifyError(error)), 400);
|
|
66
67
|
}
|
|
68
|
+
// Same rule as the outer app: an expected refusal explains itself, an unknown exception must
|
|
69
|
+
// leave a trace — otherwise the 500 is a fact without a reason anywhere (#93).
|
|
70
|
+
reportUnexpectedError(error);
|
|
67
71
|
return context.json(problem(500, "internal_error", "Internal server error"), 500);
|
|
68
72
|
});
|
|
69
73
|
app.use("*", async (context, next) => {
|
package/dist/intel/intel.js
CHANGED
|
@@ -4,6 +4,7 @@ import { handleMcp } from "../mcp/mcp.js";
|
|
|
4
4
|
import { authorize, authorizeBearer } from "../shared/gate-authorization/gate-authorization.js";
|
|
5
5
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
6
6
|
import { problemDetails } from "../shared/problem-details/problem-details.js";
|
|
7
|
+
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
7
8
|
export function createIntel(deps) {
|
|
8
9
|
const baseUrl = deps.baseUrl.replace(/\/+$/, "");
|
|
9
10
|
const resource = `${baseUrl}/mcp`;
|
|
@@ -17,6 +18,10 @@ export function createIntel(deps) {
|
|
|
17
18
|
if (error instanceof IntelError) {
|
|
18
19
|
return context.json(problemDetails(error.status, error.code, error.message), error.status);
|
|
19
20
|
}
|
|
21
|
+
// An IntelError is an expected refusal and explains itself; the unknown exception must leave a
|
|
22
|
+
// trace, or the 500 is undiagnosable — the worker answered, so the platform records no
|
|
23
|
+
// exception of its own (#93). The body stays generic: the log is the operator's channel.
|
|
24
|
+
reportUnexpectedError(error);
|
|
20
25
|
return context.json(problemDetails(500, "internal_error", "Internal server error"), 500);
|
|
21
26
|
});
|
|
22
27
|
app.get("/health", (context) => context.json({ status: "ok" }));
|
|
@@ -286,7 +286,7 @@ export function createKnowledge(deps) {
|
|
|
286
286
|
}
|
|
287
287
|
return {
|
|
288
288
|
async list(actor, input) {
|
|
289
|
-
return
|
|
289
|
+
return await deps.repository.listVisible(actor, input);
|
|
290
290
|
},
|
|
291
291
|
// The same level under a bound, for the one caller that draws a bounded picture of it. It goes
|
|
292
292
|
// through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
|
|
@@ -25,7 +25,10 @@ export interface NewKnowledgeTableVersion {
|
|
|
25
25
|
auditId: string;
|
|
26
26
|
}
|
|
27
27
|
export interface KnowledgeRepository {
|
|
28
|
-
listVisible(actor: Actor, input: ListKnowledgeNodesInput): Promise<
|
|
28
|
+
listVisible(actor: Actor, input: ListKnowledgeNodesInput): Promise<{
|
|
29
|
+
items: KnowledgeNode[];
|
|
30
|
+
withChildren: string[];
|
|
31
|
+
}>;
|
|
29
32
|
listVisibleBounded(actor: Actor, input: {
|
|
30
33
|
parentId: string | null;
|
|
31
34
|
limit: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportUnexpectedError(error: unknown): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A library error can quote whatever the peer sent — an echoed Authorization header, a token in a
|
|
2
|
+
// URL, a response body. The log must show the failure and never the credential, so everything
|
|
3
|
+
// shaped like one is cut before the line is written. The patterns match shapes, not sources: a JWT
|
|
4
|
+
// is recognizable on its own, and other tokens only ever appear behind their label.
|
|
5
|
+
function redact(text) {
|
|
6
|
+
// ⚠️ The order is load-bearing: "Authorization: Bearer x" must hit the bearer rule first — a
|
|
7
|
+
// combined alternation would let the label rule win the leftmost match, swallow the word
|
|
8
|
+
// "Bearer" as the value, and leave the token itself standing.
|
|
9
|
+
return text
|
|
10
|
+
.replace(/\beyJ[\w-]{4,}\.[\w-]+\.[\w-]*/g, "[redacted]")
|
|
11
|
+
.replace(/\b(bearer\s+)[\w.~+/-]+=*/gi, "$1[redacted]")
|
|
12
|
+
.replace(/\b((?:access_token|refresh_token|id_token|client_secret|api_key|authorization)"?\s*[:=]\s*"?)[\w.~+/-]+=*/gi, "$1[redacted]");
|
|
13
|
+
}
|
|
14
|
+
function describe(error) {
|
|
15
|
+
if (error instanceof Error)
|
|
16
|
+
return error.stack ?? `${error.name}: ${error.message}`;
|
|
17
|
+
if (typeof error === "string")
|
|
18
|
+
return error;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.stringify(error);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// openid-client wraps the informative part — the HTTP response, the OAuth error body — into
|
|
27
|
+
// `cause`, so a log line without the cause chain would name the wrapper and hide the reason (#93).
|
|
28
|
+
const MaxCauseDepth = 5;
|
|
29
|
+
export function reportUnexpectedError(error) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
let current = error;
|
|
32
|
+
for (let depth = 0; depth < MaxCauseDepth && current !== undefined; depth += 1) {
|
|
33
|
+
parts.push(describe(current));
|
|
34
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
35
|
+
}
|
|
36
|
+
console.error(redact(parts.join("\ncaused by: ")));
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.4.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
48
48
|
"ajv": "^8.20.0",
|
|
49
49
|
"hono": "^4.12.32",
|