@anchrd/intel-api 0.2.0 → 0.3.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
@@ -16,8 +16,8 @@ customer CLI.
16
16
 
17
17
  ## Requirements
18
18
 
19
- - A [Gate](https://www.npmjs.com/package/@anchrd/gate-sdk) instance — Gate owns identity, capabilities
20
- and the encrypted connection vault, and is the only required Anchrd dependency
19
+ - A [Gate](https://www.npmjs.com/package/@anchrd/gate-sdk) instance — Gate owns identity and
20
+ capabilities, and is the only required Anchrd dependency
21
21
  - A Cloudflare account with Workers, D1, R2, Queues and Workflows (Vectorize and Workers AI are
22
22
  optional and only add semantic search and attachment conversion)
23
23
  - Node 22 or newer
@@ -71,17 +71,19 @@ uses PKCE and dynamic public-client registration; browser access tokens stay ins
71
71
  `HttpOnly` cookie and are never exposed to the UI bundle or D1. Copy
72
72
  `node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars` for a local customer Worker.
73
73
 
74
- Approved MCP sources expose RFC 9728 metadata. The Tools UI follows that metadata, dynamically
75
- registers with Gate or Cloudflare Access, and completes a separate PKCE flow. Source access and
76
- refresh tokens are stored only in Gate's encrypted connection vault and refreshed just in time.
77
- The Intel audience token is never forwarded to another OAuth resource.
74
+ The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
75
+ registers with Gate or Cloudflare Access, and completes a separate PKCE flow. The only tool secret
76
+ Intel stores is the resulting per-user access token for its own portal endpoint, sealed with a key
77
+ derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider
78
+ credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
79
+ to another OAuth resource.
78
80
 
79
81
  The reference Wrangler deployment binds `DB`, `CONTENT`, `INDEXING`, `AI`, `SEARCH`, `FLOWS`, and
80
82
  `ASSETS`. Create `SEARCH` as a 1024-dimension cosine Vectorize index for the default multilingual
81
83
  Workers AI `@cf/baai/bge-m3` embedding adapter. `FLOWS` targets the exported
82
- `IntelFlowWorkflow` class. Tool sources speak standard Streamable HTTP MCP; Intel caches schemas
83
- and resolves the caller's Portal token just in time from Gate. Durable Flow state contains no
84
- browser, Gate, or provider credentials.
84
+ `IntelFlowWorkflow` class. The portal speaks standard Streamable HTTP MCP; Intel caches schemas and
85
+ unseals the caller's portal token from `portal_tokens` just in time, refreshing it shortly before
86
+ use. Durable Flow state contains no browser, Gate, or provider credentials.
85
87
 
86
88
  ## Related packages
87
89
 
@@ -12,6 +12,12 @@ const IntelConfig = z.strictObject({
12
12
  const Catalog = z.record(z.string(), z.string());
13
13
  const defaultTheme = `/* Generated by \`intel build\`. Intel defaults are active. */\n`;
14
14
  const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
15
+ // Customer paths from intel.json are written into generated CSS and TypeScript comments. A path
16
+ // containing `*/` or a newline would close the comment early, leaving broken output or — in
17
+ // custom-logo.ts — customer-controlled text outside the comment and inside the UI bundle.
18
+ function asComment(path) {
19
+ return path.replaceAll("*/", "*\\/").replace(/[\r\n]+/g, " ");
20
+ }
15
21
  function validationMessage(error) {
16
22
  return error.issues
17
23
  .map((issue) => `${issue.path.join(".") || "intel.json"}: ${issue.message}`)
@@ -64,7 +70,14 @@ export function createBuild(deps) {
64
70
  const config = parsed.data.ui;
65
71
  const englishPath = `${location.dir}/src/i18n/en.json`;
66
72
  const englishRaw = await readRequired(englishPath, "built-in English catalog");
67
- const englishParsed = Catalog.safeParse(JSON.parse(englishRaw));
73
+ let englishJson;
74
+ try {
75
+ englishJson = JSON.parse(englishRaw);
76
+ }
77
+ catch {
78
+ throw new Error(`${englishPath} is not valid JSON`);
79
+ }
80
+ const englishParsed = Catalog.safeParse(englishJson);
68
81
  if (!englishParsed.success)
69
82
  throw new Error("Intel's built-in English catalog is invalid");
70
83
  const english = englishParsed.data;
@@ -91,15 +104,15 @@ export function createBuild(deps) {
91
104
  throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is not listed in ui.languages`);
92
105
  }
93
106
  const theme = config.theme
94
- ? `/* Generated by \`intel build\` from ${config.theme}. */\n${await readRequired(config.theme, "ui.theme")}\n`
107
+ ? `/* Generated by \`intel build\` from ${asComment(config.theme)}. */\n${await readRequired(config.theme, "ui.theme")}\n`
95
108
  : defaultTheme;
96
109
  const logoSvg = config.logo ? await readSvg(config.logo, "ui.logo") : null;
97
110
  const faviconPath = config.favicon ?? config.logo;
98
111
  const faviconSvg = faviconPath
99
112
  ? await readSvg(faviconPath, config.favicon ? "ui.favicon" : "ui.logo")
100
113
  : await readRequired(`${location.dir}/src/branding/favicon.default.svg`, "default favicon");
101
- const logoModule = logoSvg
102
- ? `/* Generated by \`intel build\` from ${config.logo}. */\nexport const customLogoUrl = ${JSON.stringify(asSvgDataUrl(logoSvg))};\n`
114
+ const logoModule = config.logo && logoSvg
115
+ ? `/* Generated by \`intel build\` from ${asComment(config.logo)}. */\nexport const customLogoUrl = ${JSON.stringify(asSvgDataUrl(logoSvg))};\n`
103
116
  : defaultLogo;
104
117
  const languageBundle = `${JSON.stringify({ defaultLanguage: config.defaultLanguage, catalogs }, null, 2)}\n`;
105
118
  await deps.writeTextFile(`${location.dir}/src/theme/custom.css`, theme);
package/dist/cli/cli.js CHANGED
@@ -8,7 +8,10 @@ const interfaces = [
8
8
  functions: ["read", "create", "write", "publish", "run", "approve", "share"],
9
9
  },
10
10
  { handle: "tools", functions: ["read", "test", "execute", "admin"] },
11
- { handle: "intel-tools", functions: ["connect"] },
11
+ // `mcp:connect` heisst bei jedem MCP-Dienst dieser Installation gleich. Ein eigener Name fuer
12
+ // dieselbe Sache zwingt den Betreiber, pro Dienst nachzusehen, welches Recht den Portal-Zugang
13
+ // traegt.
14
+ { handle: "mcp", functions: ["connect"] },
12
15
  ];
13
16
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
14
17
  intel prepare Copy versioned D1 migrations to .intel/migrations
@@ -40,7 +40,10 @@ export function createIndexing(deps) {
40
40
  await deps.repository.markIndexed(versionId, deps.now().toISOString());
41
41
  }
42
42
  catch (error) {
43
- await deps.repository.markError(versionId, error instanceof Error ? error.message : "indexing_failed", deps.now().toISOString());
43
+ // Only Intel's own permanent messages are stored. A converter or fetch failure can carry
44
+ // content excerpts or URLs with query parameters, and this column is read by operators and
45
+ // shown in the UI, so an arbitrary upstream message must not be persisted here.
46
+ await deps.repository.markError(versionId, error instanceof PermanentIndexingError ? error.message.slice(0, 500) : "indexing_failed", deps.now().toISOString());
44
47
  throw error;
45
48
  }
46
49
  },
@@ -7,7 +7,11 @@ import { problemDetails } from "../shared/problem-details/problem-details.js";
7
7
  export function createIntel(deps) {
8
8
  const baseUrl = deps.baseUrl.replace(/\/+$/, "");
9
9
  const resource = `${baseUrl}/mcp`;
10
- const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
10
+ // RFC 9728 inserts the well-known segment between host and resource path: the document for a
11
+ // resource ending in /mcp belongs at /.well-known/oauth-protected-resource/mcp. A client that
12
+ // constructs that URL itself — Intel's own portal client in adapters/openid is one — finds
13
+ // nothing under the bare root path, and only a client that follows WWW-Authenticate gets through.
14
+ const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource/mcp`;
11
15
  const app = new Hono();
12
16
  app.onError((error, context) => {
13
17
  if (error instanceof IntelError) {
@@ -16,11 +20,15 @@ export function createIntel(deps) {
16
20
  return context.json(problemDetails(500, "internal_error", "Internal server error"), 500);
17
21
  });
18
22
  app.get("/health", (context) => context.json({ status: "ok" }));
19
- app.get("/.well-known/oauth-protected-resource", (context) => context.json({
23
+ const resourceMetadata = {
20
24
  resource,
21
25
  authorization_servers: [deps.gateUrl.replace(/\/+$/, "")],
22
26
  bearer_methods_supported: ["header"],
23
- }));
27
+ };
28
+ app.get("/.well-known/oauth-protected-resource/mcp", (context) => context.json(resourceMetadata));
29
+ // The root path stays as a compatibility alias: 0.2.0 published the document only here, and a
30
+ // client that stored that URL from an earlier WWW-Authenticate header keeps working.
31
+ app.get("/.well-known/oauth-protected-resource", (context) => context.json(resourceMetadata));
24
32
  const browserAuth = deps.auth;
25
33
  if (browserAuth) {
26
34
  app.get("/auth/login", async (context) => await browserAuth.login(new URL(context.req.url)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,6 +0,0 @@
1
- import type { ToolRepository } from "../../tools/tools.types.js";
2
- import type { D1Database } from "./db.types.js";
3
- export declare function createToolRepository(deps: {
4
- db: D1Database;
5
- now(): Date;
6
- }): ToolRepository;
@@ -1,200 +0,0 @@
1
- import { ToolAnnotations, ToolCapability, ToolSource, } from "@anchrd/intel-contract";
2
- const sourceColumns = `source.id, source.name, source.url, source.connection_handle,
3
- source.enabled, source.created_by, source.created_at, source.updated_at,
4
- source.last_discovered_at`;
5
- const catalogColumns = `${sourceColumns}, tool.name AS tool_name, tool.title AS tool_title,
6
- tool.description AS tool_description, tool.input_schema_json, tool.output_schema_json,
7
- tool.annotations_json, tool.fingerprint, tool.discovered_at`;
8
- // Predicate and bindings are produced together: the placeholder count depends on the roles filter,
9
- // so returning them separately would let a caller shift every binding without the compiler noticing.
10
- // An empty roles array means "any role", because `IN ()` is invalid SQL.
11
- function access(actor, now, roles = []) {
12
- const role = roles.length ? `AND grant_row.role IN (${roles.map(() => "?").join(", ")})` : "";
13
- return {
14
- sql: `(source.created_by = ? OR EXISTS (
15
- SELECT 1 FROM resource_grants grant_row
16
- WHERE grant_row.resource_type = 'tool-source' AND grant_row.resource_id = source.id
17
- AND (
18
- (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
19
- OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
20
- OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
21
- )
22
- ${role}
23
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
24
- ))`,
25
- bindings: [actor.id, actor.id, actor.email, ...roles, now],
26
- };
27
- }
28
- function mapSource(row) {
29
- return ToolSource.parse({
30
- id: row.id,
31
- name: row.name,
32
- url: row.url,
33
- connectionHandle: row.connection_handle,
34
- enabled: row.enabled === 1,
35
- createdBy: row.created_by,
36
- createdAt: row.created_at,
37
- updatedAt: row.updated_at,
38
- lastDiscoveredAt: row.last_discovered_at,
39
- });
40
- }
41
- function mapCapability(row) {
42
- return ToolCapability.parse({
43
- sourceId: row.id,
44
- name: row.tool_name,
45
- title: row.tool_title,
46
- description: row.tool_description,
47
- inputSchema: JSON.parse(row.input_schema_json),
48
- outputSchema: row.output_schema_json ? JSON.parse(row.output_schema_json) : null,
49
- annotations: ToolAnnotations.parse(JSON.parse(row.annotations_json)),
50
- fingerprint: row.fingerprint,
51
- discoveredAt: row.discovered_at,
52
- });
53
- }
54
- export function createToolRepository(deps) {
55
- const db = deps.db;
56
- return {
57
- async listVisible(actor) {
58
- const allowed = access(actor, deps.now().toISOString());
59
- const result = await db
60
- .prepare(`SELECT ${sourceColumns} FROM tool_sources source
61
- WHERE ${allowed.sql} ORDER BY lower(source.name), source.id`)
62
- .bind(...allowed.bindings)
63
- .all();
64
- return (result.results ?? []).map(mapSource);
65
- },
66
- async getVisible(actor, sourceId) {
67
- const allowed = access(actor, deps.now().toISOString());
68
- const row = await db
69
- .prepare(`SELECT ${sourceColumns} FROM tool_sources source
70
- WHERE source.id = ? AND ${allowed.sql}`)
71
- .bind(sourceId, ...allowed.bindings)
72
- .first();
73
- return row ? mapSource(row) : null;
74
- },
75
- async canManage(actor, sourceId) {
76
- const permitted = access(actor, deps.now().toISOString(), ["manager"]);
77
- const row = await db
78
- .prepare(`SELECT 1 AS allowed FROM tool_sources source
79
- WHERE source.id = ? AND ${permitted.sql}`)
80
- .bind(sourceId, ...permitted.bindings)
81
- .first();
82
- return row?.allowed === 1;
83
- },
84
- async findIdempotent(actorId, operation, key) {
85
- const row = await db
86
- .prepare(`SELECT resource_id FROM idempotency_keys
87
- WHERE actor_id = ? AND operation = ? AND idempotency_key = ?`)
88
- .bind(actorId, operation, key)
89
- .first();
90
- return row?.resource_id ?? null;
91
- },
92
- async insertSource(input) {
93
- const source = input.source;
94
- try {
95
- await db.batch([
96
- db
97
- .prepare(`INSERT INTO tool_sources (
98
- id, name, url, connection_handle, enabled, created_by,
99
- created_at, updated_at, last_discovered_at
100
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
101
- .bind(source.id, source.name, source.url, source.connectionHandle, source.enabled ? 1 : 0, source.createdBy, source.createdAt, source.updatedAt, source.lastDiscoveredAt),
102
- db
103
- .prepare(`INSERT INTO resource_grants (
104
- id, resource_type, resource_id, principal_type, principal_id, role,
105
- expires_at, created_by, created_at
106
- ) VALUES (?, 'tool-source', ?, 'organization', '*', 'viewer', NULL, ?, ?)`)
107
- .bind(input.grantId, source.id, source.createdBy, source.createdAt),
108
- db
109
- .prepare(`INSERT INTO idempotency_keys (
110
- actor_id, operation, idempotency_key, resource_id, created_at
111
- ) VALUES (?, 'tools.create', ?, ?, ?)`)
112
- .bind(source.createdBy, input.idempotencyKey, source.id, source.createdAt),
113
- db
114
- .prepare(`INSERT INTO audit_events (
115
- id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
116
- ) VALUES (?, ?, 'tools.create', 'tool-source', ?, ?, ?)`)
117
- .bind(input.auditId, source.createdBy, source.id, JSON.stringify({ name: source.name, url: source.url }), source.createdAt),
118
- ]);
119
- return source;
120
- }
121
- catch (error) {
122
- const replayedId = await this.findIdempotent(source.createdBy, "tools.create", input.idempotencyKey);
123
- const replayed = replayedId
124
- ? await db
125
- .prepare(`SELECT ${sourceColumns} FROM tool_sources source WHERE source.id = ?`)
126
- .bind(replayedId)
127
- .first()
128
- : null;
129
- if (replayed)
130
- return mapSource(replayed);
131
- throw error;
132
- }
133
- },
134
- async replaceCatalog(input) {
135
- const replayed = await this.findIdempotent(input.actorId, "tools.discover", input.idempotencyKey);
136
- if (replayed)
137
- return;
138
- const statements = [
139
- db.prepare("DELETE FROM tool_catalog WHERE source_id = ?").bind(input.sourceId),
140
- ...input.capabilities.map((capability) => db
141
- .prepare(`INSERT INTO tool_catalog (
142
- source_id, name, title, description, input_schema_json, output_schema_json,
143
- annotations_json, fingerprint, discovered_at
144
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
145
- .bind(capability.sourceId, capability.name, capability.title, capability.description, JSON.stringify(capability.inputSchema), capability.outputSchema ? JSON.stringify(capability.outputSchema) : null, JSON.stringify(capability.annotations), capability.fingerprint, capability.discoveredAt)),
146
- db
147
- .prepare(`UPDATE tool_sources
148
- SET last_discovered_at = ?, updated_at = ?
149
- WHERE id = ?`)
150
- .bind(input.discoveredAt, input.discoveredAt, input.sourceId),
151
- db
152
- .prepare(`INSERT INTO idempotency_keys (
153
- actor_id, operation, idempotency_key, resource_id, created_at
154
- ) VALUES (?, 'tools.discover', ?, ?, ?)`)
155
- .bind(input.actorId, input.idempotencyKey, input.sourceId, input.discoveredAt),
156
- db
157
- .prepare(`INSERT INTO audit_events (
158
- id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
159
- ) VALUES (?, ?, 'tools.discover', 'tool-source', ?, ?, ?)`)
160
- .bind(input.auditId, input.actorId, input.sourceId, JSON.stringify({ toolCount: input.capabilities.length }), input.discoveredAt),
161
- ];
162
- try {
163
- await db.batch(statements);
164
- }
165
- catch (error) {
166
- const concurrentReplay = await this.findIdempotent(input.actorId, "tools.discover", input.idempotencyKey);
167
- if (concurrentReplay === input.sourceId)
168
- return;
169
- throw error;
170
- }
171
- },
172
- async listCatalogVisible(actor) {
173
- const allowed = access(actor, deps.now().toISOString());
174
- const result = await db
175
- .prepare(`SELECT ${catalogColumns}
176
- FROM tool_catalog tool
177
- JOIN tool_sources source ON source.id = tool.source_id
178
- WHERE source.enabled = 1 AND ${allowed.sql}
179
- ORDER BY lower(source.name), lower(coalesce(tool.title, tool.name)), tool.name`)
180
- .bind(...allowed.bindings)
181
- .all();
182
- return (result.results ?? []).map((row) => ({
183
- source: mapSource(row),
184
- capability: mapCapability(row),
185
- }));
186
- },
187
- async getCapabilityVisible(actor, sourceId, name) {
188
- const allowed = access(actor, deps.now().toISOString());
189
- const row = await db
190
- .prepare(`SELECT ${catalogColumns}
191
- FROM tool_catalog tool
192
- JOIN tool_sources source ON source.id = tool.source_id
193
- WHERE source.id = ? AND tool.name = ? AND source.enabled = 1
194
- AND ${allowed.sql}`)
195
- .bind(sourceId, name, ...allowed.bindings)
196
- .first();
197
- return row ? mapCapability(row) : null;
198
- },
199
- };
200
- }