@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
@@ -1,130 +1,130 @@
1
- // The MCP server's `analytics_reports` tool: which reports app.analytics.report() can call, read from the HawkSearch
2
- // Analytics API's own OpenAPI spec (the developer portal), so a new report is known as soon as the analytics API
3
- // documents it. The dev spec, as the MCP only works against the dev platform, which calls the dev analytics API; the
4
- // prod spec only to mark the reports prod doesn't have yet, which an app can't use once it's live. The platform's
5
- // rules (mcp-rules.mjs) mark the reports it blocks. The specs are public: no key, no client data.
6
- import { parse } from 'yaml';
7
- import { FALLBACK } from './mcp-rules.mjs';
8
-
9
- export const SPEC_URL = 'https://developers.analytics.hawksearch.com/dev/openapi.yaml';
10
- export const PROD_SPEC_URL = 'https://developers.analytics.hawksearch.com/openapi.yaml';
11
- const PORTAL_URL = 'https://developers.analytics.hawksearch.com/';
12
- const CACHE_MS = 10 * 60_000;
13
- const TIMEOUT_MS = 10_000;
14
- const DEV_ONLY = 'dev only: not on prod yet, so an app using it fails once it is live';
15
- const BLOCKED = 'blocked by the platform: apps get 404 report_not_allowed';
16
-
17
- /** The notes on one report: dev only, blocked. */
18
- const marks = (name, prod, blocked) => [prod && !prod.has(name) ? DEV_ONLY : null, blocked.includes(name) ? BLOCKED : null].filter(Boolean);
19
-
20
- async function fetchSpec(url) {
21
- const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
22
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
23
- return response.text();
24
- }
25
-
26
- /** The reports in a parsed spec: one per POST path, by the name app.analytics.report() takes ("" for the root). */
27
- export function reportsIn(spec) {
28
- return Object.entries(spec?.paths ?? {})
29
- .filter(([, item]) => item?.post)
30
- .map(([path, item]) => ({ name: path.replace(/^\/+|\/+$/g, ''), tag: item.post.tags?.[0] ?? 'Other', summary: item.post.summary ?? '', operation: item.post }));
31
- }
32
-
33
- const call = (name) => `app.analytics.report(${JSON.stringify(name)}`;
34
- const title = (name) => (name === '' ? '"" (the root)' : name);
35
-
36
- function list(reports, url, prod, blocked) {
37
- const groups = new Map();
38
- for (const r of reports) groups.set(r.tag, [...(groups.get(r.tag) ?? []), r]);
39
- const lines = [`Analytics reports app.analytics.report(name, request) can call (scope reports:read), from ${url}. Call again with report: "<name>" for its columns, filters and an example.`];
40
- for (const [tag, items] of groups) {
41
- lines.push('', `## ${tag}`);
42
- for (const r of items) {
43
- const notes = marks(r.name, prod, blocked);
44
- lines.push(`- ${title(r.name)}${notes.length ? ` (${notes.join('; ')})` : ''}: ${r.summary}`);
45
- }
46
- }
47
- return lines.join('\n');
48
- }
49
-
50
- /** A request body's examples: inline, or through a $ref to components.requestBodies. */
51
- function requestExamples(op, spec) {
52
- const body = op.requestBody?.$ref ? spec.components?.requestBodies?.[op.requestBody.$ref.split('/').pop()] : op.requestBody;
53
- return body?.content?.['application/json']?.examples;
54
- }
55
-
56
- function detail(report, spec, prod, blocked) {
57
- const op = report.operation;
58
- const example = op.responses?.['200']?.content?.['application/json']?.example;
59
- // x-report is the spec's checked list (the analytics API's tests keep it in step); examples can be out of date.
60
- const x = op['x-report'];
61
- const columns = (x?.columns ?? example?.columns ?? []).map((c) => `${c.name} (${c.type})`);
62
- const fields = x && x.kind !== 'write'
63
- ? [`filters takes: ${Object.keys(x.filters ?? {}).join(', ') || 'none'}; having takes: ${(x.having ?? []).join(', ') || 'none'}.`]
64
- : [];
65
- const bodyExamples = requestExamples(op, spec);
66
- const sample = report.name === '' ? { query: 'top 10 keywords by searches last week' } : { startDate: '2026-06-01', endDate: '2026-06-30', page: 1, limit: 50 };
67
- return [
68
- `# ${title(report.name)}: ${report.summary}`,
69
- ...marks(report.name, prod, blocked).flatMap((note) => ['', `Note: ${note}.`]),
70
- '',
71
- (op.description ?? '').trim(),
72
- ...(columns.length ? ['', `Columns: ${columns.join(', ')}.`] : []),
73
- ...fields,
74
- '',
75
- 'From an app:',
76
- `const result = await ${call(report.name)}, ${JSON.stringify(sample)});`,
77
- report.name === ''
78
- ? ''
79
- : x
80
- ? '// result: { columns, data, totalRecords, page, hasMore }, data typed by the bridge (numbers and booleans converted). startDate/endDate default to the host\'s date range.'
81
- : '// result: { columns, data, totalRecords, page, hasMore }. startDate/endDate default to the host\'s date range; numbers and booleans can come back as strings.',
82
- ...(bodyExamples ? ['', 'Request examples:', ...Object.values(bodyExamples).map((e) => `- ${e.summary ?? ''}: ${JSON.stringify(e.value)}`)] : []),
83
- ...(example?.data?.length ? ['', `Example row: ${JSON.stringify(example.data[0])}`] : []),
84
- ]
85
- .filter((line, i, all) => line !== '' || all[i - 1] !== '')
86
- .join('\n');
87
- }
88
-
89
- /**
90
- * describe({ report }) for the tool: the list of reports, or one report in full. The specs are cached for a while; when
91
- * the portal can't be reached, an earlier copy is used if there is one. Without the prod spec, nothing is marked dev only.
92
- * `rules` is mcp-rules.mjs's createPlatformRules(); without it, no report is marked blocked (the platform's default).
93
- */
94
- export function createReportCatalog({ url = SPEC_URL, prodUrl = PROD_SPEC_URL, load = fetchSpec, now = Date.now, rules = async () => ({ rules: FALLBACK }) } = {}) {
95
- let cached;
96
- async function specs() {
97
- if (cached && now() - cached.at < CACHE_MS) return cached;
98
- const [dev, prod] = await Promise.allSettled([load(url), load(prodUrl)]);
99
- try {
100
- if (dev.status === 'rejected') throw dev.reason;
101
- const prodNames = prod.status === 'fulfilled' ? new Set(reportsIn(parse(prod.value)).map((r) => r.name)) : cached?.prod;
102
- cached = { spec: parse(dev.value), prod: prodNames, at: now() };
103
- return cached;
104
- } catch (error) {
105
- if (cached) return { ...cached, stale: `(The portal couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
106
- throw error;
107
- }
108
- }
109
-
110
- return async function describe({ report } = {}) {
111
- let loaded;
112
- try {
113
- loaded = await specs();
114
- } catch (error) {
115
- return { ok: false, text: `Couldn't read the analytics API's spec (${error.message}). The reports are documented at ${PORTAL_URL}.` };
116
- }
117
- const reports = reportsIn(loaded.spec);
118
- const platform = await rules();
119
- const blocked = platform.rules.analytics.blockedReports;
120
- const stale = [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
121
- if (report === undefined) return { ok: true, text: list(reports, url, loaded.prod, blocked) + stale };
122
-
123
- const wanted = report.trim().replace(/^\/+|\/+$/g, '').toLowerCase();
124
- const found = reports.find((r) => r.name === wanted);
125
- if (found) return { ok: true, text: detail(found, loaded.spec, loaded.prod, blocked) + stale };
126
- const words = wanted.split('-').filter(Boolean);
127
- const close = reports.filter((r) => r.name && words.some((w) => r.name.includes(w))).map((r) => r.name);
128
- return { ok: false, text: `No report "${report}" in the analytics API's spec.${close.length ? ` Close: ${close.join(', ')}.` : ''} Call without report for the list.` };
129
- };
130
- }
1
+ // The MCP server's `analytics_reports` tool: which reports app.analytics.report() can call, read from the HawkSearch
2
+ // Analytics API's own OpenAPI spec (the developer portal), so a new report is known as soon as the analytics API
3
+ // documents it. The dev spec, as the MCP only works against the dev platform, which calls the dev analytics API; the
4
+ // prod spec only to mark the reports prod doesn't have yet, which an app can't use once it's live. The platform's
5
+ // rules (mcp-rules.mjs) mark the reports it blocks. The specs are public: no key, no client data.
6
+ import { parse } from 'yaml';
7
+ import { FALLBACK } from './mcp-rules.mjs';
8
+
9
+ export const SPEC_URL = 'https://developers.analytics.hawksearch.com/dev/openapi.yaml';
10
+ export const PROD_SPEC_URL = 'https://developers.analytics.hawksearch.com/openapi.yaml';
11
+ const PORTAL_URL = 'https://developers.analytics.hawksearch.com/';
12
+ const CACHE_MS = 10 * 60_000;
13
+ const TIMEOUT_MS = 10_000;
14
+ const DEV_ONLY = 'dev only: not on prod yet, so an app using it fails once it is live';
15
+ const BLOCKED = 'blocked by the platform: apps get 404 report_not_allowed';
16
+
17
+ /** The notes on one report: dev only, blocked. */
18
+ const marks = (name, prod, blocked) => [prod && !prod.has(name) ? DEV_ONLY : null, blocked.includes(name) ? BLOCKED : null].filter(Boolean);
19
+
20
+ async function fetchSpec(url) {
21
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
22
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
23
+ return response.text();
24
+ }
25
+
26
+ /** The reports in a parsed spec: one per POST path, by the name app.analytics.report() takes ("" for the root). */
27
+ export function reportsIn(spec) {
28
+ return Object.entries(spec?.paths ?? {})
29
+ .filter(([, item]) => item?.post)
30
+ .map(([path, item]) => ({ name: path.replace(/^\/+|\/+$/g, ''), tag: item.post.tags?.[0] ?? 'Other', summary: item.post.summary ?? '', operation: item.post }));
31
+ }
32
+
33
+ const call = (name) => `app.analytics.report(${JSON.stringify(name)}`;
34
+ const title = (name) => (name === '' ? '"" (the root)' : name);
35
+
36
+ function list(reports, url, prod, blocked) {
37
+ const groups = new Map();
38
+ for (const r of reports) groups.set(r.tag, [...(groups.get(r.tag) ?? []), r]);
39
+ const lines = [`Analytics reports app.analytics.report(name, request) can call (scope reports:read), from ${url}. Call again with report: "<name>" for its columns, filters and an example.`];
40
+ for (const [tag, items] of groups) {
41
+ lines.push('', `## ${tag}`);
42
+ for (const r of items) {
43
+ const notes = marks(r.name, prod, blocked);
44
+ lines.push(`- ${title(r.name)}${notes.length ? ` (${notes.join('; ')})` : ''}: ${r.summary}`);
45
+ }
46
+ }
47
+ return lines.join('\n');
48
+ }
49
+
50
+ /** A request body's examples: inline, or through a $ref to components.requestBodies. */
51
+ function requestExamples(op, spec) {
52
+ const body = op.requestBody?.$ref ? spec.components?.requestBodies?.[op.requestBody.$ref.split('/').pop()] : op.requestBody;
53
+ return body?.content?.['application/json']?.examples;
54
+ }
55
+
56
+ function detail(report, spec, prod, blocked) {
57
+ const op = report.operation;
58
+ const example = op.responses?.['200']?.content?.['application/json']?.example;
59
+ // x-report is the spec's checked list (the analytics API's tests keep it in step); examples can be out of date.
60
+ const x = op['x-report'];
61
+ const columns = (x?.columns ?? example?.columns ?? []).map((c) => `${c.name} (${c.type})`);
62
+ const fields = x && x.kind !== 'write'
63
+ ? [`filters takes: ${Object.keys(x.filters ?? {}).join(', ') || 'none'}; having takes: ${(x.having ?? []).join(', ') || 'none'}.`]
64
+ : [];
65
+ const bodyExamples = requestExamples(op, spec);
66
+ const sample = report.name === '' ? { query: 'top 10 keywords by searches last week' } : { startDate: '2026-06-01', endDate: '2026-06-30', page: 1, limit: 50 };
67
+ return [
68
+ `# ${title(report.name)}: ${report.summary}`,
69
+ ...marks(report.name, prod, blocked).flatMap((note) => ['', `Note: ${note}.`]),
70
+ '',
71
+ (op.description ?? '').trim(),
72
+ ...(columns.length ? ['', `Columns: ${columns.join(', ')}.`] : []),
73
+ ...fields,
74
+ '',
75
+ 'From an app:',
76
+ `const result = await ${call(report.name)}, ${JSON.stringify(sample)});`,
77
+ report.name === ''
78
+ ? ''
79
+ : x
80
+ ? '// result: { columns, data, totalRecords, page, hasMore }, data typed by the bridge (numbers and booleans converted). startDate/endDate default to the host\'s date range.'
81
+ : '// result: { columns, data, totalRecords, page, hasMore }. startDate/endDate default to the host\'s date range; numbers and booleans can come back as strings.',
82
+ ...(bodyExamples ? ['', 'Request examples:', ...Object.values(bodyExamples).map((e) => `- ${e.summary ?? ''}: ${JSON.stringify(e.value)}`)] : []),
83
+ ...(example?.data?.length ? ['', `Example row: ${JSON.stringify(example.data[0])}`] : []),
84
+ ]
85
+ .filter((line, i, all) => line !== '' || all[i - 1] !== '')
86
+ .join('\n');
87
+ }
88
+
89
+ /**
90
+ * describe({ report }) for the tool: the list of reports, or one report in full. The specs are cached for a while; when
91
+ * the portal can't be reached, an earlier copy is used if there is one. Without the prod spec, nothing is marked dev only.
92
+ * `rules` is mcp-rules.mjs's createPlatformRules(); without it, no report is marked blocked (the platform's default).
93
+ */
94
+ export function createReportCatalog({ url = SPEC_URL, prodUrl = PROD_SPEC_URL, load = fetchSpec, now = Date.now, rules = async () => ({ rules: FALLBACK }) } = {}) {
95
+ let cached;
96
+ async function specs() {
97
+ if (cached && now() - cached.at < CACHE_MS) return cached;
98
+ const [dev, prod] = await Promise.allSettled([load(url), load(prodUrl)]);
99
+ try {
100
+ if (dev.status === 'rejected') throw dev.reason;
101
+ const prodNames = prod.status === 'fulfilled' ? new Set(reportsIn(parse(prod.value)).map((r) => r.name)) : cached?.prod;
102
+ cached = { spec: parse(dev.value), prod: prodNames, at: now() };
103
+ return cached;
104
+ } catch (error) {
105
+ if (cached) return { ...cached, stale: `(The portal couldn't be reached just now: ${error.message}. This is the copy from earlier.)` };
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ return async function describe({ report } = {}) {
111
+ let loaded;
112
+ try {
113
+ loaded = await specs();
114
+ } catch (error) {
115
+ return { ok: false, text: `Couldn't read the analytics API's spec (${error.message}). The reports are documented at ${PORTAL_URL}.` };
116
+ }
117
+ const reports = reportsIn(loaded.spec);
118
+ const platform = await rules();
119
+ const blocked = platform.rules.analytics.blockedReports;
120
+ const stale = [loaded.stale, platform.note].filter(Boolean).map((note) => `\n\n${note}`).join('');
121
+ if (report === undefined) return { ok: true, text: list(reports, url, loaded.prod, blocked) + stale };
122
+
123
+ const wanted = report.trim().replace(/^\/+|\/+$/g, '').toLowerCase();
124
+ const found = reports.find((r) => r.name === wanted);
125
+ if (found) return { ok: true, text: detail(found, loaded.spec, loaded.prod, blocked) + stale };
126
+ const words = wanted.split('-').filter(Boolean);
127
+ const close = reports.filter((r) => r.name && words.some((w) => r.name.includes(w))).map((r) => r.name);
128
+ return { ok: false, text: `No report "${report}" in the analytics API's spec.${close.length ? ` Close: ${close.join(', ')}.` : ''} Call without report for the list.` };
129
+ };
130
+ }
package/src/mcp-rules.mjs CHANGED
@@ -1,109 +1,109 @@
1
- // What apps may call, for the MCP server's hawksearch_api and analytics_reports tools: the platform's own
2
- // GET /meta/rules (the rules in effect there, configuration included), read from the dev platform the MCP deploys to.
3
- // FALLBACK is the platform's built-in defaults, for when the platform can't be reached (or predates /meta/rules);
4
- // test/mcp-hawksearch.test.mjs checks it against platform/src. Public: no key, no client data.
5
- import { PLATFORMS } from './env.mjs';
6
-
7
- const CACHE_MS = 10 * 60_000;
8
- const TIMEOUT_MS = 10_000;
9
-
10
- const index = (method, operation, path, scope) => ({ method, operation, path, scope });
11
- const READ = 'hawksearch:index';
12
- const ITEMS = 'hawksearch:index:items';
13
- const MANAGE = 'hawksearch:index:manage';
14
-
15
- /** The platform's defaults (HawkSearchOptions, IndexingApiRules, AnalyticsOptions), in /meta/rules' shape. */
16
- export const FALLBACK = {
17
- version: 1,
18
- dashboard: {
19
- apiVersion: 'v11',
20
- writeResources: ['banner', 'boostbury', 'campaign', 'visibility', 'visitortarget', 'redirect', 'synonym', 'spellcheck', 'paginationset', 'sortingset', 'recommendationvisibility', 'landingpagelayout', 'facet', 'landingpage'],
21
- maxBulkItems: 1000,
22
- maxWriteRequestBytes: 5 * 1024 * 1024,
23
- writesPerMinute: 120,
24
- },
25
- search: { perMinute: 600, maxRequestBytes: 64 * 1024 },
26
- recommendations: { available: true, maxBulkWidgets: 10, perMinute: 600 },
27
- indexing: {
28
- operations: [
29
- index('GET', 'indexes', 'api/v2/indexing', READ),
30
- index('GET', 'current', 'api/v2/indexing/current', READ),
31
- index('GET', 'inactive', 'api/v2/indexing/get-inactive-index', READ),
32
- index('POST', 'hierarchy', 'api/hierarchy', READ),
33
- index('POST', 'mappings', 'api/mapping', READ),
34
- index('GET', 'embedding/progress', 'api/embedding/progress', READ),
35
- index('POST', 'index-items', 'api/v2/indexing/index-items', ITEMS),
36
- index('POST', 'update-items', 'api/v2/indexing/update-items', ITEMS),
37
- index('POST', 'delete', 'api/v2/indexing/delete', ITEMS),
38
- index('POST', 'delete-items', 'api/v2/indexing/delete-items', ITEMS),
39
- index('POST', 'add-attributes-to-items', 'api/v2/indexing/add-attributes-to-items', ITEMS),
40
- index('POST', 'update-attributes-of-items', 'api/v2/indexing/update-attributes-of-items', ITEMS),
41
- index('POST', 'remove-attributes-from-items', 'api/v2/indexing/remove-attributes-from-items', ITEMS),
42
- index('POST', 'update-nested', 'api/v2/indexing/update-nested', ITEMS),
43
- index('POST', 'remove-nested', 'api/v2/indexing/remove-nested', ITEMS),
44
- index('POST', 'bulk-update-nested', 'api/v2/indexing/bulk-update-nested', ITEMS),
45
- index('POST', 'bulk-remove-nested', 'api/v2/indexing/bulk-remove-nested', ITEMS),
46
- index('POST', 'bulk-item-operations', 'api/v2/indexing/bulk-item-operations', ITEMS),
47
- index('POST', 'create', 'api/v2/indexing/create', MANAGE),
48
- index('POST', 'set-current', 'api/v2/indexing/set-current', MANAGE),
49
- index('POST', 'swap', 'api/v2/indexing/swap', MANAGE),
50
- index('POST', 'delete-index', 'api/v2/indexing/delete-index', MANAGE),
51
- index('POST', 'rebuild-all', 'api/v2/indexing/rebuild-all', MANAGE),
52
- index('POST', 'rebuild-autocomplete', 'api/v2/indexing/rebuild-autocomplete', MANAGE),
53
- index('POST', 'rebuild-percolator', 'api/v2/indexing/rebuild-percolator', MANAGE),
54
- index('POST', 'rebuild-learning', 'api/v2/indexing/rebuild-learning', MANAGE),
55
- index('POST', 'rebuild-related', 'api/v2/indexing/rebuild-related', MANAGE),
56
- index('POST', 'rebuild-embedding', 'api/v2/indexing/rebuild-embedding', MANAGE),
57
- index('POST', 'hierarchy/upsert', 'api/hierarchy/upsert', MANAGE),
58
- index('POST', 'hierarchy/rebuild', 'api/hierarchy/rebuild', MANAGE),
59
- index('POST', 'hierarchy/delete-items', 'api/hierarchy/delete-items', MANAGE),
60
- index('POST', 'hierarchy/delete-all', 'api/hierarchy/delete-all', MANAGE),
61
- index('POST', 'mappings/upsert', 'api/mapping/upsert', MANAGE),
62
- index('POST', 'mappings/invalidate-cache', 'api/mapping/invalidate-cache', MANAGE),
63
- index('GET', 'embedding/status/{id}', 'api/embedding/status/{id}', READ),
64
- ],
65
- maxItemsPerCall: 125,
66
- maxIdsPerCall: 1000,
67
- maxRequestBytes: 5 * 1024 * 1024,
68
- itemWritesPerMinute: 120,
69
- managePerMinute: 10,
70
- rebuildsPerMinute: 2,
71
- },
72
- analytics: { blockedReports: [] },
73
- };
74
-
75
- /** The platform's answer over the fallback, section by section, so a field it doesn't send keeps its default. */
76
- function merge(rules) {
77
- if (!rules || typeof rules !== 'object' || !(rules.version >= 1)) throw new Error('not a rules document');
78
- const merged = { ...FALLBACK };
79
- for (const [section, defaults] of Object.entries(FALLBACK)) {
80
- if (rules[section] && typeof rules[section] === 'object' && typeof defaults === 'object') merged[section] = { ...defaults, ...rules[section] };
81
- }
82
- return { ...merged, scopes: rules.scopes, version: rules.version };
83
- }
84
-
85
- async function fetchJson(url) {
86
- const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
87
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
88
- return response.json();
89
- }
90
-
91
- /**
92
- * rules() for the tools: { rules, from } from the platform (cached for a while), else the earlier answer, else
93
- * { rules: FALLBACK, from, note } saying why.
94
- */
95
- export function createPlatformRules({ url = PLATFORMS.dev, load = fetchJson, now = Date.now } = {}) {
96
- const endpoint = `${url.replace(/\/+$/, '')}/meta/rules`;
97
- let cached;
98
- return async function rules() {
99
- if (cached && now() - cached.at < CACHE_MS) return cached.answer;
100
- try {
101
- cached = { answer: { rules: merge(await load(endpoint)), from: endpoint }, at: now() };
102
- return cached.answer;
103
- } catch (error) {
104
- if (cached) return { ...cached.answer, note: `(${endpoint} couldn't be read just now: ${error.message}. These are its rules from earlier.)` };
105
- // Not cached: the platform is tried again on the next call.
106
- return { rules: FALLBACK, from: 'the CLI', note: `(${endpoint} couldn't be read: ${error.message}. These are the platform's default rules, which its configuration can change.)` };
107
- }
108
- };
109
- }
1
+ // What apps may call, for the MCP server's hawksearch_api and analytics_reports tools: the platform's own
2
+ // GET /meta/rules (the rules in effect there, configuration included), read from the dev platform the MCP deploys to.
3
+ // FALLBACK is the platform's built-in defaults, for when the platform can't be reached (or predates /meta/rules);
4
+ // test/mcp-hawksearch.test.mjs checks it against platform/src. Public: no key, no client data.
5
+ import { PLATFORMS } from './env.mjs';
6
+
7
+ const CACHE_MS = 10 * 60_000;
8
+ const TIMEOUT_MS = 10_000;
9
+
10
+ const index = (method, operation, path, scope) => ({ method, operation, path, scope });
11
+ const READ = 'hawksearch:index';
12
+ const ITEMS = 'hawksearch:index:items';
13
+ const MANAGE = 'hawksearch:index:manage';
14
+
15
+ /** The platform's defaults (HawkSearchOptions, IndexingApiRules, AnalyticsOptions), in /meta/rules' shape. */
16
+ export const FALLBACK = {
17
+ version: 1,
18
+ dashboard: {
19
+ apiVersion: 'v11',
20
+ writeResources: ['banner', 'boostbury', 'campaign', 'visibility', 'visitortarget', 'redirect', 'synonym', 'spellcheck', 'paginationset', 'sortingset', 'recommendationvisibility', 'landingpagelayout', 'facet', 'landingpage'],
21
+ maxBulkItems: 1000,
22
+ maxWriteRequestBytes: 5 * 1024 * 1024,
23
+ writesPerMinute: 120,
24
+ },
25
+ search: { perMinute: 600, maxRequestBytes: 64 * 1024 },
26
+ recommendations: { available: true, maxBulkWidgets: 10, perMinute: 600 },
27
+ indexing: {
28
+ operations: [
29
+ index('GET', 'indexes', 'api/v2/indexing', READ),
30
+ index('GET', 'current', 'api/v2/indexing/current', READ),
31
+ index('GET', 'inactive', 'api/v2/indexing/get-inactive-index', READ),
32
+ index('POST', 'hierarchy', 'api/hierarchy', READ),
33
+ index('POST', 'mappings', 'api/mapping', READ),
34
+ index('GET', 'embedding/progress', 'api/embedding/progress', READ),
35
+ index('POST', 'index-items', 'api/v2/indexing/index-items', ITEMS),
36
+ index('POST', 'update-items', 'api/v2/indexing/update-items', ITEMS),
37
+ index('POST', 'delete', 'api/v2/indexing/delete', ITEMS),
38
+ index('POST', 'delete-items', 'api/v2/indexing/delete-items', ITEMS),
39
+ index('POST', 'add-attributes-to-items', 'api/v2/indexing/add-attributes-to-items', ITEMS),
40
+ index('POST', 'update-attributes-of-items', 'api/v2/indexing/update-attributes-of-items', ITEMS),
41
+ index('POST', 'remove-attributes-from-items', 'api/v2/indexing/remove-attributes-from-items', ITEMS),
42
+ index('POST', 'update-nested', 'api/v2/indexing/update-nested', ITEMS),
43
+ index('POST', 'remove-nested', 'api/v2/indexing/remove-nested', ITEMS),
44
+ index('POST', 'bulk-update-nested', 'api/v2/indexing/bulk-update-nested', ITEMS),
45
+ index('POST', 'bulk-remove-nested', 'api/v2/indexing/bulk-remove-nested', ITEMS),
46
+ index('POST', 'bulk-item-operations', 'api/v2/indexing/bulk-item-operations', ITEMS),
47
+ index('POST', 'create', 'api/v2/indexing/create', MANAGE),
48
+ index('POST', 'set-current', 'api/v2/indexing/set-current', MANAGE),
49
+ index('POST', 'swap', 'api/v2/indexing/swap', MANAGE),
50
+ index('POST', 'delete-index', 'api/v2/indexing/delete-index', MANAGE),
51
+ index('POST', 'rebuild-all', 'api/v2/indexing/rebuild-all', MANAGE),
52
+ index('POST', 'rebuild-autocomplete', 'api/v2/indexing/rebuild-autocomplete', MANAGE),
53
+ index('POST', 'rebuild-percolator', 'api/v2/indexing/rebuild-percolator', MANAGE),
54
+ index('POST', 'rebuild-learning', 'api/v2/indexing/rebuild-learning', MANAGE),
55
+ index('POST', 'rebuild-related', 'api/v2/indexing/rebuild-related', MANAGE),
56
+ index('POST', 'rebuild-embedding', 'api/v2/indexing/rebuild-embedding', MANAGE),
57
+ index('POST', 'hierarchy/upsert', 'api/hierarchy/upsert', MANAGE),
58
+ index('POST', 'hierarchy/rebuild', 'api/hierarchy/rebuild', MANAGE),
59
+ index('POST', 'hierarchy/delete-items', 'api/hierarchy/delete-items', MANAGE),
60
+ index('POST', 'hierarchy/delete-all', 'api/hierarchy/delete-all', MANAGE),
61
+ index('POST', 'mappings/upsert', 'api/mapping/upsert', MANAGE),
62
+ index('POST', 'mappings/invalidate-cache', 'api/mapping/invalidate-cache', MANAGE),
63
+ index('GET', 'embedding/status/{id}', 'api/embedding/status/{id}', READ),
64
+ ],
65
+ maxItemsPerCall: 125,
66
+ maxIdsPerCall: 1000,
67
+ maxRequestBytes: 5 * 1024 * 1024,
68
+ itemWritesPerMinute: 120,
69
+ managePerMinute: 10,
70
+ rebuildsPerMinute: 2,
71
+ },
72
+ analytics: { blockedReports: [] },
73
+ };
74
+
75
+ /** The platform's answer over the fallback, section by section, so a field it doesn't send keeps its default. */
76
+ function merge(rules) {
77
+ if (!rules || typeof rules !== 'object' || !(rules.version >= 1)) throw new Error('not a rules document');
78
+ const merged = { ...FALLBACK };
79
+ for (const [section, defaults] of Object.entries(FALLBACK)) {
80
+ if (rules[section] && typeof rules[section] === 'object' && typeof defaults === 'object') merged[section] = { ...defaults, ...rules[section] };
81
+ }
82
+ return { ...merged, scopes: rules.scopes, version: rules.version };
83
+ }
84
+
85
+ async function fetchJson(url) {
86
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept: 'application/json' } });
87
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
88
+ return response.json();
89
+ }
90
+
91
+ /**
92
+ * rules() for the tools: { rules, from } from the platform (cached for a while), else the earlier answer, else
93
+ * { rules: FALLBACK, from, note } saying why.
94
+ */
95
+ export function createPlatformRules({ url = PLATFORMS.dev, load = fetchJson, now = Date.now } = {}) {
96
+ const endpoint = `${url.replace(/\/+$/, '')}/meta/rules`;
97
+ let cached;
98
+ return async function rules() {
99
+ if (cached && now() - cached.at < CACHE_MS) return cached.answer;
100
+ try {
101
+ cached = { answer: { rules: merge(await load(endpoint)), from: endpoint }, at: now() };
102
+ return cached.answer;
103
+ } catch (error) {
104
+ if (cached) return { ...cached.answer, note: `(${endpoint} couldn't be read just now: ${error.message}. These are its rules from earlier.)` };
105
+ // Not cached: the platform is tried again on the next call.
106
+ return { rules: FALLBACK, from: 'the CLI', note: `(${endpoint} couldn't be read: ${error.message}. These are the platform's default rules, which its configuration can change.)` };
107
+ }
108
+ };
109
+ }