@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.
- package/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/src/manifest.mjs
CHANGED
|
@@ -1,425 +1,425 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { FUNCTION_NAME, FUNCTIONS_SCOPE, MAX_NAME_LENGTH, validateFunctionsManifest } from './functions.mjs';
|
|
3
|
-
import { ICON_PATH, ICON_PATH_HINT } from './app-icon.mjs';
|
|
4
|
-
import { validateStore } from './store-listing.mjs';
|
|
5
|
-
|
|
6
|
-
export const MANIFEST_FILE = 'hawk-app.json';
|
|
7
|
-
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
8
|
-
const VISIBILITIES = ['allowList', 'all', 'internal', 'store'];
|
|
9
|
-
const SCOPES = ['reports:read', 'hawksearch:read', 'hawksearch:write', 'data:read', 'functions:invoke', 'email:send', 'hawksearch:sysparams:write', 'hawksearch:search', 'ai:invoke', 'lambdas:invoke', 'hawksearch:features:write', 'hawksearch:index', 'hawksearch:index:items', 'hawksearch:index:manage', 'hawksearch:recommendations', 'storage', 'assistant'];
|
|
10
|
-
export const SYSPARAMS_SCOPE = 'hawksearch:sysparams:write';
|
|
11
|
-
/** The templates `hawk-app create` offers (the keys of create.mjs's TEMPLATES): `"template"` in hawk-app.json. */
|
|
12
|
-
export const TEMPLATE_NAMES = ['react', 'preact', 'next', 'next-mui', 'vanilla'];
|
|
13
|
-
export const LAMBDAS_SCOPE = 'lambdas:invoke';
|
|
14
|
-
// Same rules as the platform: Lambda function names, at most 10.
|
|
15
|
-
const LAMBDA_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
16
|
-
const MAX_LAMBDAS = 10;
|
|
17
|
-
// Same rules as the platform: HawkSearch system parameter names the app may read and set.
|
|
18
|
-
const SYSPARAM_NAME = /^[A-Za-z0-9_.-]{1,50}$/;
|
|
19
|
-
const MAX_SYSPARAMS = 50;
|
|
20
|
-
// Same rules as the platform: schedules run job functions, at most hourly (EventBridge Scheduler cron).
|
|
21
|
-
export const MAX_SCHEDULES = 5;
|
|
22
|
-
const CRON_MINUTES = /^([0-9]|[1-5][0-9])$/;
|
|
23
|
-
const CRON_FIELD = /^[0-9A-Za-z*?,/#-]{1,40}$/;
|
|
24
|
-
const TIMEZONE = /^(UTC|[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)+)$/;
|
|
25
|
-
// Same message as the platform: the registry is the only source of truth for which clients have the app.
|
|
26
|
-
export const CLIENTS_NOT_IN_MANIFEST =
|
|
27
|
-
'clients and excludeClients are no longer set in hawk-app.json: manage clients in the Hawk Analytics portal or with `hawk-app clients`.';
|
|
28
|
-
const STATUS_IGNORED = 'status is ignored: an approver releases versions from the Apps page in HawkSearch.';
|
|
29
|
-
|
|
30
|
-
export async function loadManifest(file = MANIFEST_FILE) {
|
|
31
|
-
let text;
|
|
32
|
-
try {
|
|
33
|
-
text = await readFile(file, 'utf8');
|
|
34
|
-
} catch {
|
|
35
|
-
throw new Error(`Cannot read ${file}. Run \`hawk-app init\` to create one.`);
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(text);
|
|
39
|
-
} catch (error) {
|
|
40
|
-
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Local checks so mistakes show up before anything is sent. The platform validates again on
|
|
46
|
-
* `hawk-app register` (`hawk-app register --dry-run` shows its verdict without changing anything).
|
|
47
|
-
* @returns {{ errors: string[], warnings: string[] }}
|
|
48
|
-
*/
|
|
49
|
-
// Same rules as the platform (ExternalOrigins.cs): https or wss, optional "*." subdomain wildcard, optional port, no path.
|
|
50
|
-
const ORIGIN = /^(https|wss):\/\/(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]*[a-z0-9])?(:[0-9]{1,5})?$/;
|
|
51
|
-
const MAX_ORIGINS = 20;
|
|
52
|
-
|
|
53
|
-
// Same rules as the platform (AppViewRules): views are pages of the app's build that hosts show inside other pages.
|
|
54
|
-
const VIEW_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
55
|
-
const VIEW_PATH = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
56
|
-
const VIEW_PARAM = /^[a-z][A-Za-z0-9]{0,39}$/;
|
|
57
|
-
const VIEW_KEYS = ['path', 'title', 'height', 'embeddable', 'params', 'cacheMinutes', 'trial'];
|
|
58
|
-
// Same as the platform (AppStoreRules.TrialScopes): what a store trial's token may have, at most.
|
|
59
|
-
export const TRIAL_SCOPES = ['reports:read', 'hawksearch:read', 'hawksearch:search', 'hawksearch:recommendations', 'data:read'];
|
|
60
|
-
|
|
61
|
-
function validateViews(m) {
|
|
62
|
-
const errors = [];
|
|
63
|
-
const views = m.views;
|
|
64
|
-
if (views === undefined || views === null) return errors;
|
|
65
|
-
if (typeof views !== 'object' || Array.isArray(views)) {
|
|
66
|
-
return ['views must be an object: { "preview": { "path": "views/preview", "title": "…" } }.'];
|
|
67
|
-
}
|
|
68
|
-
const names = Object.keys(views);
|
|
69
|
-
if (names.length > 10) errors.push('views: at most 10 views.');
|
|
70
|
-
for (const name of names) {
|
|
71
|
-
const where = `views.${name}`;
|
|
72
|
-
const view = views[name];
|
|
73
|
-
if (!VIEW_NAME.test(name) || name.length > 40) {
|
|
74
|
-
errors.push(`views: "${name}" must be lowercase letters, digits and single dashes (at most 40).`);
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (!view || typeof view !== 'object' || Array.isArray(view)) {
|
|
78
|
-
errors.push(`${where} must be an object.`);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
const unknown = Object.keys(view).filter((k) => !VIEW_KEYS.includes(k));
|
|
82
|
-
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${VIEW_KEYS.join(', ')}).`);
|
|
83
|
-
if (typeof view.path !== 'string' || view.path.length > 200 || !VIEW_PATH.test(view.path) || view.path.split('/').some((s) => s === '.' || s === '..')) {
|
|
84
|
-
errors.push(`${where}.path must be a page of the app's build, like "views/${name}" (letters, digits, . _ - and /; no leading /).`);
|
|
85
|
-
}
|
|
86
|
-
if (typeof view.title !== 'string' || !view.title.trim() || view.title.length > 80) errors.push(`${where}.title is required, at most 80 characters.`);
|
|
87
|
-
if (view.height !== undefined && !(Number.isInteger(view.height) && view.height >= 80 && view.height <= 1200)) {
|
|
88
|
-
errors.push(`${where}.height must be a whole number of pixels from 80 to 1200 (default 300).`);
|
|
89
|
-
}
|
|
90
|
-
if (view.embeddable !== undefined && typeof view.embeddable !== 'boolean') errors.push(`${where}.embeddable must be true or false.`);
|
|
91
|
-
if (view.cacheMinutes !== undefined && !(Number.isInteger(view.cacheMinutes) && view.cacheMinutes >= 0 && view.cacheMinutes <= 1440)) {
|
|
92
|
-
errors.push(`${where}.cacheMinutes must be a whole number of minutes from 0 (not cached) to 1440.`);
|
|
93
|
-
}
|
|
94
|
-
if (view.params !== undefined) {
|
|
95
|
-
if (!Array.isArray(view.params) || view.params.some((p) => typeof p !== 'string')) {
|
|
96
|
-
errors.push(`${where}.params must be a list of names, e.g. ["sku"].`);
|
|
97
|
-
} else {
|
|
98
|
-
const bad = view.params.filter((p) => !VIEW_PARAM.test(p));
|
|
99
|
-
if (bad.length) errors.push(`${where}.params: ${bad.map((p) => `"${p}"`).join(', ')} must be camelCase names.`);
|
|
100
|
-
if (new Set(view.params).size !== view.params.length) errors.push(`${where}.params lists a name more than once.`);
|
|
101
|
-
if (view.params.length > 10) errors.push(`${where}.params: at most 10.`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (view.trial !== undefined && typeof view.trial !== 'boolean') errors.push(`${where}.trial must be true or false.`);
|
|
105
|
-
if (view.trial === true && Array.isArray(view.params) && view.params.length) errors.push(`${where}: a trial view takes no params (the store card has none to pass).`);
|
|
106
|
-
}
|
|
107
|
-
if (names.filter((name) => views[name]?.trial === true).length > 1) errors.push('views: only one view can be the trial view ("trial": true).');
|
|
108
|
-
return errors;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** The trial view's warnings: only store apps show it, and its token lacks the app's other scopes. */
|
|
112
|
-
function trialWarnings(m) {
|
|
113
|
-
const views = m.views && typeof m.views === 'object' && !Array.isArray(m.views) ? m.views : {};
|
|
114
|
-
const trial = Object.keys(views).find((name) => views[name]?.trial === true);
|
|
115
|
-
if (!trial) return [];
|
|
116
|
-
if (m.visibility !== 'store') return [`views.${trial} is a trial view, but only store apps (visibility "store") show one.`];
|
|
117
|
-
const missing = (Array.isArray(m.scopes) ? m.scopes : []).filter((s) => !TRIAL_SCOPES.includes(s));
|
|
118
|
-
return missing.length
|
|
119
|
-
? [`views.${trial}: a trial's token is read-only, without ${missing.join(', ')}. Hide what needs them while app.context.trial is true.`]
|
|
120
|
-
: [];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Same rules as the platform: app config, named non-secret values set outside the manifest (portal or `hawk-app config`).
|
|
124
|
-
export const CONFIG_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
125
|
-
export const CONFIG_TYPES = ['string', 'number', 'boolean', 'emails', 'json'];
|
|
126
|
-
const CONFIG_FIELDS = ['type', 'label', 'description', 'default'];
|
|
127
|
-
const MAX_CONFIG_KEYS = 50;
|
|
128
|
-
const MAX_CONFIG_STRING = 4000;
|
|
129
|
-
const MAX_CONFIG_EMAILS = 50;
|
|
130
|
-
export const MAX_CONFIG_VALUE_BYTES = 16 * 1024;
|
|
131
|
-
// Same rule as `POST /v1/email` `to` (the platform's EmailRules.IsValidAddress): ASCII local@domain.tld, no display
|
|
132
|
-
// names, no whitespace, local part at most 64 characters, at most 254 in all.
|
|
133
|
-
const EMAIL_ADDRESS = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
|
134
|
-
|
|
135
|
-
export function isEmailAddress(address) {
|
|
136
|
-
if (typeof address !== 'string' || !address || address.length > 254) return false;
|
|
137
|
-
const at = address.indexOf('@');
|
|
138
|
-
return at > 0 && at <= 64 && EMAIL_ADDRESS.test(address);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Why `value` isn't a valid value of a config `type`, as an error starting with `field` (e.g. "signupNotifyEmails must
|
|
143
|
-
* be a list of email addresses."), or null when it is. The same rules and messages as the platform (AppConfigRules):
|
|
144
|
-
* they apply to `default` and to every value set later.
|
|
145
|
-
*/
|
|
146
|
-
export function configValueError(type, value, field) {
|
|
147
|
-
if (value !== undefined && Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') > MAX_CONFIG_VALUE_BYTES) {
|
|
148
|
-
return `${field} is larger than ${MAX_CONFIG_VALUE_BYTES / 1024} KB.`;
|
|
149
|
-
}
|
|
150
|
-
switch (type) {
|
|
151
|
-
case 'string':
|
|
152
|
-
return typeof value === 'string' && value.length <= MAX_CONFIG_STRING ? null : `${field} must be a string of up to ${MAX_CONFIG_STRING} characters.`;
|
|
153
|
-
case 'number':
|
|
154
|
-
return typeof value === 'number' && Number.isFinite(value) ? null : `${field} must be a number.`;
|
|
155
|
-
case 'boolean':
|
|
156
|
-
return typeof value === 'boolean' ? null : `${field} must be true or false.`;
|
|
157
|
-
case 'emails':
|
|
158
|
-
if (!Array.isArray(value) || !value.every(isEmailAddress)) return `${field} must be a list of email addresses.`;
|
|
159
|
-
if (value.length > MAX_CONFIG_EMAILS) return `${field} must be a list of at most ${MAX_CONFIG_EMAILS} email addresses.`;
|
|
160
|
-
return new Set(value.map((a) => a.toLowerCase())).size !== value.length ? `${field} lists the same email address more than once.` : null;
|
|
161
|
-
case 'json':
|
|
162
|
-
return value === null || value === undefined ? `${field} must be a JSON value (not null).` : null;
|
|
163
|
-
default:
|
|
164
|
-
return `${field} has an unknown type.`;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* "config": { "<key>": { "type", "label"?, "description"?, "default"? } }, at most 50 keys. The same checks and messages
|
|
170
|
-
* as the platform (AppConfigRules.ParseManifest): an entry with unknown fields is reported for those only, and its
|
|
171
|
-
* default is checked only when the rest of the entry is valid.
|
|
172
|
-
*/
|
|
173
|
-
export function validateConfig(m) {
|
|
174
|
-
const errors = [];
|
|
175
|
-
const config = m.config;
|
|
176
|
-
if (config === undefined || config === null) return errors;
|
|
177
|
-
if (typeof config !== 'object' || Array.isArray(config)) return ['config must be an object: { "<key>": { "type": "string" } }.'];
|
|
178
|
-
const keys = Object.keys(config);
|
|
179
|
-
if (keys.length > MAX_CONFIG_KEYS) errors.push(`config can declare at most ${MAX_CONFIG_KEYS} keys.`);
|
|
180
|
-
for (const key of keys) {
|
|
181
|
-
const field = `config.${key}`;
|
|
182
|
-
if (!CONFIG_KEY.test(key)) {
|
|
183
|
-
errors.push(`config: "${key}" must start with a letter, then letters, digits and underscores (at most 64).`);
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
const entry = config[key];
|
|
187
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
188
|
-
errors.push(`${field} must be an object: { "type": "string" }.`);
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
const unknown = Object.keys(entry).filter((k) => !CONFIG_FIELDS.includes(k));
|
|
192
|
-
if (unknown.length) {
|
|
193
|
-
errors.push(`${field}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${CONFIG_FIELDS.join(', ')}).`);
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const before = errors.length;
|
|
197
|
-
if (!CONFIG_TYPES.includes(entry.type)) errors.push(`${field}.type is required: one of ${CONFIG_TYPES.join(', ')}.`);
|
|
198
|
-
if (entry.label !== undefined && (typeof entry.label !== 'string' || !entry.label.trim() || entry.label.trim().length > 100)) {
|
|
199
|
-
errors.push(`${field}.label must be a string of 1 to 100 characters.`);
|
|
200
|
-
}
|
|
201
|
-
if (entry.description !== undefined && (typeof entry.description !== 'string' || entry.description.trim().length > 500)) {
|
|
202
|
-
errors.push(`${field}.description must be a string of up to 500 characters.`);
|
|
203
|
-
}
|
|
204
|
-
if (Object.hasOwn(entry, 'default') && errors.length === before) {
|
|
205
|
-
const problem = configValueError(entry.type, entry.default, `${field}.default`);
|
|
206
|
-
if (problem) errors.push(problem);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return errors;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function validateManifest(manifest) {
|
|
213
|
-
const errors = [];
|
|
214
|
-
const warnings = [];
|
|
215
|
-
const m = manifest ?? {};
|
|
216
|
-
|
|
217
|
-
if (typeof m.slug !== 'string' || !SLUG.test(m.slug) || m.slug.length > 64) {
|
|
218
|
-
errors.push('slug must be 1-64 lowercase letters, digits and single dashes (e.g. "keyword-explorer").');
|
|
219
|
-
}
|
|
220
|
-
if (typeof m.name !== 'string' || !m.name.trim() || m.name.length > 100) {
|
|
221
|
-
errors.push('name is required (up to 100 characters).');
|
|
222
|
-
}
|
|
223
|
-
if (!VISIBILITIES.includes(m.visibility)) {
|
|
224
|
-
errors.push(`visibility must be one of: ${VISIBILITIES.join(', ')}.`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (m.clients !== undefined || m.excludeClients !== undefined) errors.push(CLIENTS_NOT_IN_MANIFEST);
|
|
228
|
-
|
|
229
|
-
// The file itself (there, type, size, square) is checked by validate and deploy (app-icon.mjs).
|
|
230
|
-
if (m.icon !== undefined && (typeof m.icon !== 'string' || !ICON_PATH.test(m.icon))) {
|
|
231
|
-
errors.push(`icon must be ${ICON_PATH_HINT}, not ${JSON.stringify(m.icon)} (icon names from HawkSearch's set are no longer used).`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (m.listed !== undefined && typeof m.listed !== 'boolean') {
|
|
235
|
-
errors.push('listed must be true or false.');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (m.hosting !== undefined && m.hosting !== 'static') {
|
|
239
|
-
errors.push(
|
|
240
|
-
m.hosting === 'server'
|
|
241
|
-
? 'hosting "server" (server-rendered apps) is not available yet. Use "static": build to static files and deploy them with hawk-app deploy.'
|
|
242
|
-
: `hosting must be "static" (or "server" once server-rendered apps are supported), not "${m.hosting}".`,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (m.externalOrigins !== undefined) {
|
|
247
|
-
const origins = m.externalOrigins;
|
|
248
|
-
if (!Array.isArray(origins) || origins.some((o) => typeof o !== 'string')) {
|
|
249
|
-
errors.push('externalOrigins must be a list, e.g. ["*"] or ["https://api.example.com"].');
|
|
250
|
-
} else if (origins.includes('*') && origins.length > 1) {
|
|
251
|
-
errors.push('externalOrigins: "*" already allows every origin, so list it on its own.');
|
|
252
|
-
} else {
|
|
253
|
-
const norm = (o) => o.trim().replace(/\/$/, '').toLowerCase();
|
|
254
|
-
const isImage = (o) => norm(o).startsWith('img:');
|
|
255
|
-
const bad = origins.filter((o) => o !== '*' && !isImage(o) && !ORIGIN.test(norm(o)));
|
|
256
|
-
const badImages = origins.filter((o) => isImage(o) && norm(o) !== 'img:*' && !(norm(o).startsWith('img:https://') && ORIGIN.test(norm(o).slice(4))));
|
|
257
|
-
if (badImages.length) {
|
|
258
|
-
errors.push(`externalOrigins: ${badImages.map((o) => `"${o}"`).join(', ')} must be image origins like img:https://cdn.example.com, or img:* for images from any https origin.`);
|
|
259
|
-
}
|
|
260
|
-
if (origins.map(norm).includes('img:*') && origins.filter(isImage).length > 1) {
|
|
261
|
-
errors.push('externalOrigins: "img:*" already allows images from every https origin, so drop the other img: entries.');
|
|
262
|
-
}
|
|
263
|
-
if (bad.length) {
|
|
264
|
-
errors.push(`externalOrigins: ${bad.map((o) => `"${o}"`).join(', ')} must be origins like https://api.example.com or https://*.example.com (https or wss, no path).`);
|
|
265
|
-
}
|
|
266
|
-
if (origins.length > MAX_ORIGINS) errors.push(`externalOrigins: at most ${MAX_ORIGINS} origins (or "*" for any).`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
errors.push(...validateFunctionsManifest(m));
|
|
271
|
-
errors.push(...validateSystemParameters(m));
|
|
272
|
-
errors.push(...validateLambdas(m));
|
|
273
|
-
if (m.template !== undefined && !TEMPLATE_NAMES.includes(m.template)) {
|
|
274
|
-
errors.push(`template must be one of ${TEMPLATE_NAMES.join(', ')} (the template the app was created from; \`hawk-app upgrade\` uses it).`);
|
|
275
|
-
}
|
|
276
|
-
errors.push(...validateSchedules(m));
|
|
277
|
-
errors.push(...validateViews(m));
|
|
278
|
-
errors.push(...validateConfig(m));
|
|
279
|
-
const store = validateStore(m);
|
|
280
|
-
errors.push(...store.errors);
|
|
281
|
-
warnings.push(...store.warnings);
|
|
282
|
-
warnings.push(...trialWarnings(m));
|
|
283
|
-
|
|
284
|
-
const unknownScopes = (m.scopes ?? []).filter((s) => !SCOPES.includes(s));
|
|
285
|
-
if (unknownScopes.length) warnings.push(`These scopes are not available and will not be granted: ${unknownScopes.join(', ')}.`);
|
|
286
|
-
// Status is host-controlled now: approvers release versions in HawkSearch.
|
|
287
|
-
if (m.status !== undefined) warnings.push(STATUS_IGNORED);
|
|
288
|
-
|
|
289
|
-
return { errors, warnings };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* "lambdas": Lambdas outside Hawk Apps the app calls (scope lambdas:invoke). A list of up to 10 unique Lambda function
|
|
294
|
-
* names; the list needs the scope and the scope needs a non-empty list. Whether a platform allows a name is only known
|
|
295
|
-
* when the app calls it.
|
|
296
|
-
*/
|
|
297
|
-
export function validateLambdas(m) {
|
|
298
|
-
const errors = [];
|
|
299
|
-
const hasScope = Array.isArray(m.scopes) && m.scopes.includes(LAMBDAS_SCOPE);
|
|
300
|
-
const list = m.lambdas;
|
|
301
|
-
if (list === undefined) {
|
|
302
|
-
if (hasScope) errors.push(`The "${LAMBDAS_SCOPE}" scope needs "lambdas": the Lambdas the app calls, e.g. ["analytics-client-setup"].`);
|
|
303
|
-
return errors;
|
|
304
|
-
}
|
|
305
|
-
if (!Array.isArray(list) || list.some((name) => typeof name !== 'string')) {
|
|
306
|
-
errors.push('lambdas must be a list of Lambda function names, e.g. ["analytics-client-setup"].');
|
|
307
|
-
return errors;
|
|
308
|
-
}
|
|
309
|
-
const bad = list.filter((name) => !LAMBDA_NAME.test(name));
|
|
310
|
-
if (bad.length) errors.push(`lambdas: ${bad.map((name) => JSON.stringify(name)).join(', ')} must be a Lambda function name: 1-64 letters, digits, "-" or "_".`);
|
|
311
|
-
const duplicates = [...new Set(list.filter((name, i) => list.indexOf(name) !== i))];
|
|
312
|
-
if (duplicates.length) errors.push(`lambdas lists ${duplicates.join(', ')} more than once.`);
|
|
313
|
-
if (list.length > MAX_LAMBDAS) errors.push(`lambdas: at most ${MAX_LAMBDAS} names (found ${list.length}).`);
|
|
314
|
-
if (list.length && !hasScope) errors.push(`lambdas need the "${LAMBDAS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
315
|
-
if (!list.length && hasScope) errors.push(`The "${LAMBDAS_SCOPE}" scope needs at least one name in "lambdas".`);
|
|
316
|
-
return errors;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* "systemParameters": the HawkSearch system parameters the app may set (scope hawksearch:sysparams:write). A list of
|
|
321
|
-
* up to 50 unique names (case-insensitive); the list needs the scope and the scope needs a non-empty list.
|
|
322
|
-
*/
|
|
323
|
-
export function validateSystemParameters(m) {
|
|
324
|
-
const errors = [];
|
|
325
|
-
const hasScope = Array.isArray(m.scopes) && m.scopes.includes(SYSPARAMS_SCOPE);
|
|
326
|
-
const list = m.systemParameters;
|
|
327
|
-
if (list === undefined) {
|
|
328
|
-
if (hasScope) errors.push(`The "${SYSPARAMS_SCOPE}" scope needs "systemParameters": the system parameters the app may set, e.g. ["Timezone"].`);
|
|
329
|
-
return errors;
|
|
330
|
-
}
|
|
331
|
-
if (!Array.isArray(list) || list.some((name) => typeof name !== 'string')) {
|
|
332
|
-
errors.push('systemParameters must be a list of HawkSearch system parameter names, e.g. ["Timezone", "SearchPreviewURL"].');
|
|
333
|
-
return errors;
|
|
334
|
-
}
|
|
335
|
-
const bad = list.filter((name) => !SYSPARAM_NAME.test(name));
|
|
336
|
-
if (bad.length) {
|
|
337
|
-
errors.push(`systemParameters: ${bad.map((name) => JSON.stringify(name)).join(', ')} must be 1-50 letters, digits, "_", "." or "-".`);
|
|
338
|
-
}
|
|
339
|
-
const lower = list.map((name) => name.toLowerCase());
|
|
340
|
-
const duplicates = [...new Set(list.filter((name, i) => lower.indexOf(name.toLowerCase()) !== i))];
|
|
341
|
-
if (duplicates.length) errors.push(`systemParameters lists ${duplicates.join(', ')} more than once (names are not case-sensitive).`);
|
|
342
|
-
if (list.length > MAX_SYSPARAMS) errors.push(`systemParameters: at most ${MAX_SYSPARAMS} names (found ${list.length}).`);
|
|
343
|
-
if (list.length && !hasScope) errors.push(`systemParameters need the "${SYSPARAMS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
344
|
-
if (!list.length && hasScope) errors.push(`The "${SYSPARAMS_SCOPE}" scope needs at least one name in "systemParameters".`);
|
|
345
|
-
return errors;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* "schedules": recurring runs of the app's job functions, { "<name>": { "function", "cron", "timezone"? } }. Up to 5;
|
|
350
|
-
* each runs a function of this manifest with "job": true, on a 6-field EventBridge cron
|
|
351
|
-
* ("minutes hours day-of-month month day-of-week year") at most hourly: the minutes field is a single number.
|
|
352
|
-
*/
|
|
353
|
-
export function validateSchedules(m) {
|
|
354
|
-
const errors = [];
|
|
355
|
-
const schedules = m.schedules;
|
|
356
|
-
if (schedules === undefined) return errors;
|
|
357
|
-
if (!schedules || typeof schedules !== 'object' || Array.isArray(schedules)) {
|
|
358
|
-
errors.push('schedules must be an object: { "<name>": { "function": "<job function>", "cron": "0 7 ? * MON *" } }.');
|
|
359
|
-
return errors;
|
|
360
|
-
}
|
|
361
|
-
const names = Object.keys(schedules);
|
|
362
|
-
if (names.length > MAX_SCHEDULES) errors.push(`schedules: at most ${MAX_SCHEDULES} schedules per app (found ${names.length}).`);
|
|
363
|
-
const functions = m.functions && typeof m.functions === 'object' && !Array.isArray(m.functions) ? m.functions : {};
|
|
364
|
-
for (const name of names) {
|
|
365
|
-
const where = `schedules.${name}`;
|
|
366
|
-
if (!FUNCTION_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
367
|
-
errors.push(`${where}: schedule names must be 1-${MAX_NAME_LENGTH} lowercase letters, digits and single dashes (e.g. "nightly").`);
|
|
368
|
-
}
|
|
369
|
-
const schedule = schedules[name];
|
|
370
|
-
if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) {
|
|
371
|
-
errors.push(`${where} must be an object, e.g. { "function": "nightly-report", "cron": "0 7 ? * MON *", "timezone": "America/Chicago" }.`);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const unknown = Object.keys(schedule).filter((key) => !['function', 'cron', 'timezone'].includes(key));
|
|
375
|
-
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: function, cron, timezone).`);
|
|
376
|
-
if (typeof schedule.function !== 'string' || !schedule.function) {
|
|
377
|
-
errors.push(`${where}.function is required: the name of a job function under "functions".`);
|
|
378
|
-
} else if (!Object.hasOwn(functions, schedule.function)) {
|
|
379
|
-
errors.push(`${where}.function: "${schedule.function}" is not declared under "functions".`);
|
|
380
|
-
} else if (functions[schedule.function]?.job !== true) {
|
|
381
|
-
errors.push(`${where}.function: "${schedule.function}" must be a job function: set "job": true on it.`);
|
|
382
|
-
}
|
|
383
|
-
if (typeof schedule.cron !== 'string') {
|
|
384
|
-
errors.push(`${where}.cron is required: an EventBridge cron like "0 7 ? * MON *" (minutes hours day-of-month month day-of-week year).`);
|
|
385
|
-
} else {
|
|
386
|
-
const fields = schedule.cron.trim().split(/\s+/);
|
|
387
|
-
if (/^cron\(/i.test(schedule.cron.trim())) {
|
|
388
|
-
errors.push(`${where}.cron: leave out the cron( ) wrapper, e.g. "0 7 ? * MON *".`);
|
|
389
|
-
} else if (fields.length !== 6) {
|
|
390
|
-
errors.push(`${where}.cron must have 6 fields: minutes hours day-of-month month day-of-week year, e.g. "0 7 ? * MON *" (found ${fields.length}).`);
|
|
391
|
-
} else if (fields.some((field) => !CRON_FIELD.test(field))) {
|
|
392
|
-
errors.push(`${where}.cron: each field may only use digits, letters and * ? , / # - (found "${schedule.cron}").`);
|
|
393
|
-
} else if (!CRON_MINUTES.test(fields[0])) {
|
|
394
|
-
errors.push(`${where}.cron: schedules run at most hourly, so the minutes field must be a single number from 0 to 59 (found "${fields[0]}").`);
|
|
395
|
-
} else if (fields[2] !== '?' && fields[4] !== '?') {
|
|
396
|
-
errors.push(`${where}.cron: day-of-month or day-of-week must be "?" (EventBridge needs one of them), e.g. "0 7 ? * MON *" or "0 7 1 * ? *".`);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
if (schedule.timezone !== undefined && (typeof schedule.timezone !== 'string' || !TIMEZONE.test(schedule.timezone))) {
|
|
400
|
-
errors.push(`${where}.timezone must be an IANA time zone such as "America/Chicago", or "UTC" (the default).`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (names.length && !(m.scopes ?? []).includes(FUNCTIONS_SCOPE)) {
|
|
404
|
-
errors.push(`schedules need the "${FUNCTIONS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
405
|
-
}
|
|
406
|
-
return errors;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function newManifest({ slug, name, template }) {
|
|
410
|
-
return {
|
|
411
|
-
$schema: './node_modules/@bridgeline-digital/hawk-app-cli/schema/hawk-app.schema.json',
|
|
412
|
-
slug,
|
|
413
|
-
// The template it came from: `hawk-app upgrade` keeps the app in step with it.
|
|
414
|
-
...(template ? { template } : {}),
|
|
415
|
-
name,
|
|
416
|
-
description: '',
|
|
417
|
-
// An .svg (drawn in the host's color) or .png file next to this one; create and init add a default icon.svg.
|
|
418
|
-
icon: 'icon.svg',
|
|
419
|
-
visibility: 'allowList',
|
|
420
|
-
scopes: ['reports:read'],
|
|
421
|
-
// Outside origins the app may load scripts, styles and images from and call (e.g. "https://api.example.com",
|
|
422
|
-
// or "img:*" for images from anywhere). The vanilla template loads the bridge from jsDelivr.
|
|
423
|
-
externalOrigins: template === 'vanilla' ? ['https://cdn.jsdelivr.net'] : [],
|
|
424
|
-
};
|
|
425
|
-
}
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { FUNCTION_NAME, FUNCTIONS_SCOPE, MAX_NAME_LENGTH, validateFunctionsManifest } from './functions.mjs';
|
|
3
|
+
import { ICON_PATH, ICON_PATH_HINT } from './app-icon.mjs';
|
|
4
|
+
import { validateStore } from './store-listing.mjs';
|
|
5
|
+
|
|
6
|
+
export const MANIFEST_FILE = 'hawk-app.json';
|
|
7
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
8
|
+
const VISIBILITIES = ['allowList', 'all', 'internal', 'store'];
|
|
9
|
+
const SCOPES = ['reports:read', 'hawksearch:read', 'hawksearch:write', 'data:read', 'functions:invoke', 'email:send', 'hawksearch:sysparams:write', 'hawksearch:search', 'ai:invoke', 'lambdas:invoke', 'hawksearch:features:write', 'hawksearch:index', 'hawksearch:index:items', 'hawksearch:index:manage', 'hawksearch:recommendations', 'storage', 'assistant'];
|
|
10
|
+
export const SYSPARAMS_SCOPE = 'hawksearch:sysparams:write';
|
|
11
|
+
/** The templates `hawk-app create` offers (the keys of create.mjs's TEMPLATES): `"template"` in hawk-app.json. */
|
|
12
|
+
export const TEMPLATE_NAMES = ['react', 'preact', 'next', 'next-mui', 'vanilla'];
|
|
13
|
+
export const LAMBDAS_SCOPE = 'lambdas:invoke';
|
|
14
|
+
// Same rules as the platform: Lambda function names, at most 10.
|
|
15
|
+
const LAMBDA_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
16
|
+
const MAX_LAMBDAS = 10;
|
|
17
|
+
// Same rules as the platform: HawkSearch system parameter names the app may read and set.
|
|
18
|
+
const SYSPARAM_NAME = /^[A-Za-z0-9_.-]{1,50}$/;
|
|
19
|
+
const MAX_SYSPARAMS = 50;
|
|
20
|
+
// Same rules as the platform: schedules run job functions, at most hourly (EventBridge Scheduler cron).
|
|
21
|
+
export const MAX_SCHEDULES = 5;
|
|
22
|
+
const CRON_MINUTES = /^([0-9]|[1-5][0-9])$/;
|
|
23
|
+
const CRON_FIELD = /^[0-9A-Za-z*?,/#-]{1,40}$/;
|
|
24
|
+
const TIMEZONE = /^(UTC|[A-Za-z0-9_+-]+(\/[A-Za-z0-9_+-]+)+)$/;
|
|
25
|
+
// Same message as the platform: the registry is the only source of truth for which clients have the app.
|
|
26
|
+
export const CLIENTS_NOT_IN_MANIFEST =
|
|
27
|
+
'clients and excludeClients are no longer set in hawk-app.json: manage clients in the Hawk Analytics portal or with `hawk-app clients`.';
|
|
28
|
+
const STATUS_IGNORED = 'status is ignored: an approver releases versions from the Apps page in HawkSearch.';
|
|
29
|
+
|
|
30
|
+
export async function loadManifest(file = MANIFEST_FILE) {
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(file, 'utf8');
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`Cannot read ${file}. Run \`hawk-app init\` to create one.`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Local checks so mistakes show up before anything is sent. The platform validates again on
|
|
46
|
+
* `hawk-app register` (`hawk-app register --dry-run` shows its verdict without changing anything).
|
|
47
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
48
|
+
*/
|
|
49
|
+
// Same rules as the platform (ExternalOrigins.cs): https or wss, optional "*." subdomain wildcard, optional port, no path.
|
|
50
|
+
const ORIGIN = /^(https|wss):\/\/(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]*[a-z0-9])?(:[0-9]{1,5})?$/;
|
|
51
|
+
const MAX_ORIGINS = 20;
|
|
52
|
+
|
|
53
|
+
// Same rules as the platform (AppViewRules): views are pages of the app's build that hosts show inside other pages.
|
|
54
|
+
const VIEW_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
55
|
+
const VIEW_PATH = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
56
|
+
const VIEW_PARAM = /^[a-z][A-Za-z0-9]{0,39}$/;
|
|
57
|
+
const VIEW_KEYS = ['path', 'title', 'height', 'embeddable', 'params', 'cacheMinutes', 'trial'];
|
|
58
|
+
// Same as the platform (AppStoreRules.TrialScopes): what a store trial's token may have, at most.
|
|
59
|
+
export const TRIAL_SCOPES = ['reports:read', 'hawksearch:read', 'hawksearch:search', 'hawksearch:recommendations', 'data:read'];
|
|
60
|
+
|
|
61
|
+
function validateViews(m) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
const views = m.views;
|
|
64
|
+
if (views === undefined || views === null) return errors;
|
|
65
|
+
if (typeof views !== 'object' || Array.isArray(views)) {
|
|
66
|
+
return ['views must be an object: { "preview": { "path": "views/preview", "title": "…" } }.'];
|
|
67
|
+
}
|
|
68
|
+
const names = Object.keys(views);
|
|
69
|
+
if (names.length > 10) errors.push('views: at most 10 views.');
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const where = `views.${name}`;
|
|
72
|
+
const view = views[name];
|
|
73
|
+
if (!VIEW_NAME.test(name) || name.length > 40) {
|
|
74
|
+
errors.push(`views: "${name}" must be lowercase letters, digits and single dashes (at most 40).`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) {
|
|
78
|
+
errors.push(`${where} must be an object.`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const unknown = Object.keys(view).filter((k) => !VIEW_KEYS.includes(k));
|
|
82
|
+
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${VIEW_KEYS.join(', ')}).`);
|
|
83
|
+
if (typeof view.path !== 'string' || view.path.length > 200 || !VIEW_PATH.test(view.path) || view.path.split('/').some((s) => s === '.' || s === '..')) {
|
|
84
|
+
errors.push(`${where}.path must be a page of the app's build, like "views/${name}" (letters, digits, . _ - and /; no leading /).`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof view.title !== 'string' || !view.title.trim() || view.title.length > 80) errors.push(`${where}.title is required, at most 80 characters.`);
|
|
87
|
+
if (view.height !== undefined && !(Number.isInteger(view.height) && view.height >= 80 && view.height <= 1200)) {
|
|
88
|
+
errors.push(`${where}.height must be a whole number of pixels from 80 to 1200 (default 300).`);
|
|
89
|
+
}
|
|
90
|
+
if (view.embeddable !== undefined && typeof view.embeddable !== 'boolean') errors.push(`${where}.embeddable must be true or false.`);
|
|
91
|
+
if (view.cacheMinutes !== undefined && !(Number.isInteger(view.cacheMinutes) && view.cacheMinutes >= 0 && view.cacheMinutes <= 1440)) {
|
|
92
|
+
errors.push(`${where}.cacheMinutes must be a whole number of minutes from 0 (not cached) to 1440.`);
|
|
93
|
+
}
|
|
94
|
+
if (view.params !== undefined) {
|
|
95
|
+
if (!Array.isArray(view.params) || view.params.some((p) => typeof p !== 'string')) {
|
|
96
|
+
errors.push(`${where}.params must be a list of names, e.g. ["sku"].`);
|
|
97
|
+
} else {
|
|
98
|
+
const bad = view.params.filter((p) => !VIEW_PARAM.test(p));
|
|
99
|
+
if (bad.length) errors.push(`${where}.params: ${bad.map((p) => `"${p}"`).join(', ')} must be camelCase names.`);
|
|
100
|
+
if (new Set(view.params).size !== view.params.length) errors.push(`${where}.params lists a name more than once.`);
|
|
101
|
+
if (view.params.length > 10) errors.push(`${where}.params: at most 10.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (view.trial !== undefined && typeof view.trial !== 'boolean') errors.push(`${where}.trial must be true or false.`);
|
|
105
|
+
if (view.trial === true && Array.isArray(view.params) && view.params.length) errors.push(`${where}: a trial view takes no params (the store card has none to pass).`);
|
|
106
|
+
}
|
|
107
|
+
if (names.filter((name) => views[name]?.trial === true).length > 1) errors.push('views: only one view can be the trial view ("trial": true).');
|
|
108
|
+
return errors;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The trial view's warnings: only store apps show it, and its token lacks the app's other scopes. */
|
|
112
|
+
function trialWarnings(m) {
|
|
113
|
+
const views = m.views && typeof m.views === 'object' && !Array.isArray(m.views) ? m.views : {};
|
|
114
|
+
const trial = Object.keys(views).find((name) => views[name]?.trial === true);
|
|
115
|
+
if (!trial) return [];
|
|
116
|
+
if (m.visibility !== 'store') return [`views.${trial} is a trial view, but only store apps (visibility "store") show one.`];
|
|
117
|
+
const missing = (Array.isArray(m.scopes) ? m.scopes : []).filter((s) => !TRIAL_SCOPES.includes(s));
|
|
118
|
+
return missing.length
|
|
119
|
+
? [`views.${trial}: a trial's token is read-only, without ${missing.join(', ')}. Hide what needs them while app.context.trial is true.`]
|
|
120
|
+
: [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Same rules as the platform: app config, named non-secret values set outside the manifest (portal or `hawk-app config`).
|
|
124
|
+
export const CONFIG_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
125
|
+
export const CONFIG_TYPES = ['string', 'number', 'boolean', 'emails', 'json'];
|
|
126
|
+
const CONFIG_FIELDS = ['type', 'label', 'description', 'default'];
|
|
127
|
+
const MAX_CONFIG_KEYS = 50;
|
|
128
|
+
const MAX_CONFIG_STRING = 4000;
|
|
129
|
+
const MAX_CONFIG_EMAILS = 50;
|
|
130
|
+
export const MAX_CONFIG_VALUE_BYTES = 16 * 1024;
|
|
131
|
+
// Same rule as `POST /v1/email` `to` (the platform's EmailRules.IsValidAddress): ASCII local@domain.tld, no display
|
|
132
|
+
// names, no whitespace, local part at most 64 characters, at most 254 in all.
|
|
133
|
+
const EMAIL_ADDRESS = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
|
|
134
|
+
|
|
135
|
+
export function isEmailAddress(address) {
|
|
136
|
+
if (typeof address !== 'string' || !address || address.length > 254) return false;
|
|
137
|
+
const at = address.indexOf('@');
|
|
138
|
+
return at > 0 && at <= 64 && EMAIL_ADDRESS.test(address);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Why `value` isn't a valid value of a config `type`, as an error starting with `field` (e.g. "signupNotifyEmails must
|
|
143
|
+
* be a list of email addresses."), or null when it is. The same rules and messages as the platform (AppConfigRules):
|
|
144
|
+
* they apply to `default` and to every value set later.
|
|
145
|
+
*/
|
|
146
|
+
export function configValueError(type, value, field) {
|
|
147
|
+
if (value !== undefined && Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8') > MAX_CONFIG_VALUE_BYTES) {
|
|
148
|
+
return `${field} is larger than ${MAX_CONFIG_VALUE_BYTES / 1024} KB.`;
|
|
149
|
+
}
|
|
150
|
+
switch (type) {
|
|
151
|
+
case 'string':
|
|
152
|
+
return typeof value === 'string' && value.length <= MAX_CONFIG_STRING ? null : `${field} must be a string of up to ${MAX_CONFIG_STRING} characters.`;
|
|
153
|
+
case 'number':
|
|
154
|
+
return typeof value === 'number' && Number.isFinite(value) ? null : `${field} must be a number.`;
|
|
155
|
+
case 'boolean':
|
|
156
|
+
return typeof value === 'boolean' ? null : `${field} must be true or false.`;
|
|
157
|
+
case 'emails':
|
|
158
|
+
if (!Array.isArray(value) || !value.every(isEmailAddress)) return `${field} must be a list of email addresses.`;
|
|
159
|
+
if (value.length > MAX_CONFIG_EMAILS) return `${field} must be a list of at most ${MAX_CONFIG_EMAILS} email addresses.`;
|
|
160
|
+
return new Set(value.map((a) => a.toLowerCase())).size !== value.length ? `${field} lists the same email address more than once.` : null;
|
|
161
|
+
case 'json':
|
|
162
|
+
return value === null || value === undefined ? `${field} must be a JSON value (not null).` : null;
|
|
163
|
+
default:
|
|
164
|
+
return `${field} has an unknown type.`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* "config": { "<key>": { "type", "label"?, "description"?, "default"? } }, at most 50 keys. The same checks and messages
|
|
170
|
+
* as the platform (AppConfigRules.ParseManifest): an entry with unknown fields is reported for those only, and its
|
|
171
|
+
* default is checked only when the rest of the entry is valid.
|
|
172
|
+
*/
|
|
173
|
+
export function validateConfig(m) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
const config = m.config;
|
|
176
|
+
if (config === undefined || config === null) return errors;
|
|
177
|
+
if (typeof config !== 'object' || Array.isArray(config)) return ['config must be an object: { "<key>": { "type": "string" } }.'];
|
|
178
|
+
const keys = Object.keys(config);
|
|
179
|
+
if (keys.length > MAX_CONFIG_KEYS) errors.push(`config can declare at most ${MAX_CONFIG_KEYS} keys.`);
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
const field = `config.${key}`;
|
|
182
|
+
if (!CONFIG_KEY.test(key)) {
|
|
183
|
+
errors.push(`config: "${key}" must start with a letter, then letters, digits and underscores (at most 64).`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const entry = config[key];
|
|
187
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
188
|
+
errors.push(`${field} must be an object: { "type": "string" }.`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const unknown = Object.keys(entry).filter((k) => !CONFIG_FIELDS.includes(k));
|
|
192
|
+
if (unknown.length) {
|
|
193
|
+
errors.push(`${field}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: ${CONFIG_FIELDS.join(', ')}).`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const before = errors.length;
|
|
197
|
+
if (!CONFIG_TYPES.includes(entry.type)) errors.push(`${field}.type is required: one of ${CONFIG_TYPES.join(', ')}.`);
|
|
198
|
+
if (entry.label !== undefined && (typeof entry.label !== 'string' || !entry.label.trim() || entry.label.trim().length > 100)) {
|
|
199
|
+
errors.push(`${field}.label must be a string of 1 to 100 characters.`);
|
|
200
|
+
}
|
|
201
|
+
if (entry.description !== undefined && (typeof entry.description !== 'string' || entry.description.trim().length > 500)) {
|
|
202
|
+
errors.push(`${field}.description must be a string of up to 500 characters.`);
|
|
203
|
+
}
|
|
204
|
+
if (Object.hasOwn(entry, 'default') && errors.length === before) {
|
|
205
|
+
const problem = configValueError(entry.type, entry.default, `${field}.default`);
|
|
206
|
+
if (problem) errors.push(problem);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return errors;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function validateManifest(manifest) {
|
|
213
|
+
const errors = [];
|
|
214
|
+
const warnings = [];
|
|
215
|
+
const m = manifest ?? {};
|
|
216
|
+
|
|
217
|
+
if (typeof m.slug !== 'string' || !SLUG.test(m.slug) || m.slug.length > 64) {
|
|
218
|
+
errors.push('slug must be 1-64 lowercase letters, digits and single dashes (e.g. "keyword-explorer").');
|
|
219
|
+
}
|
|
220
|
+
if (typeof m.name !== 'string' || !m.name.trim() || m.name.length > 100) {
|
|
221
|
+
errors.push('name is required (up to 100 characters).');
|
|
222
|
+
}
|
|
223
|
+
if (!VISIBILITIES.includes(m.visibility)) {
|
|
224
|
+
errors.push(`visibility must be one of: ${VISIBILITIES.join(', ')}.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (m.clients !== undefined || m.excludeClients !== undefined) errors.push(CLIENTS_NOT_IN_MANIFEST);
|
|
228
|
+
|
|
229
|
+
// The file itself (there, type, size, square) is checked by validate and deploy (app-icon.mjs).
|
|
230
|
+
if (m.icon !== undefined && (typeof m.icon !== 'string' || !ICON_PATH.test(m.icon))) {
|
|
231
|
+
errors.push(`icon must be ${ICON_PATH_HINT}, not ${JSON.stringify(m.icon)} (icon names from HawkSearch's set are no longer used).`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (m.listed !== undefined && typeof m.listed !== 'boolean') {
|
|
235
|
+
errors.push('listed must be true or false.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (m.hosting !== undefined && m.hosting !== 'static') {
|
|
239
|
+
errors.push(
|
|
240
|
+
m.hosting === 'server'
|
|
241
|
+
? 'hosting "server" (server-rendered apps) is not available yet. Use "static": build to static files and deploy them with hawk-app deploy.'
|
|
242
|
+
: `hosting must be "static" (or "server" once server-rendered apps are supported), not "${m.hosting}".`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (m.externalOrigins !== undefined) {
|
|
247
|
+
const origins = m.externalOrigins;
|
|
248
|
+
if (!Array.isArray(origins) || origins.some((o) => typeof o !== 'string')) {
|
|
249
|
+
errors.push('externalOrigins must be a list, e.g. ["*"] or ["https://api.example.com"].');
|
|
250
|
+
} else if (origins.includes('*') && origins.length > 1) {
|
|
251
|
+
errors.push('externalOrigins: "*" already allows every origin, so list it on its own.');
|
|
252
|
+
} else {
|
|
253
|
+
const norm = (o) => o.trim().replace(/\/$/, '').toLowerCase();
|
|
254
|
+
const isImage = (o) => norm(o).startsWith('img:');
|
|
255
|
+
const bad = origins.filter((o) => o !== '*' && !isImage(o) && !ORIGIN.test(norm(o)));
|
|
256
|
+
const badImages = origins.filter((o) => isImage(o) && norm(o) !== 'img:*' && !(norm(o).startsWith('img:https://') && ORIGIN.test(norm(o).slice(4))));
|
|
257
|
+
if (badImages.length) {
|
|
258
|
+
errors.push(`externalOrigins: ${badImages.map((o) => `"${o}"`).join(', ')} must be image origins like img:https://cdn.example.com, or img:* for images from any https origin.`);
|
|
259
|
+
}
|
|
260
|
+
if (origins.map(norm).includes('img:*') && origins.filter(isImage).length > 1) {
|
|
261
|
+
errors.push('externalOrigins: "img:*" already allows images from every https origin, so drop the other img: entries.');
|
|
262
|
+
}
|
|
263
|
+
if (bad.length) {
|
|
264
|
+
errors.push(`externalOrigins: ${bad.map((o) => `"${o}"`).join(', ')} must be origins like https://api.example.com or https://*.example.com (https or wss, no path).`);
|
|
265
|
+
}
|
|
266
|
+
if (origins.length > MAX_ORIGINS) errors.push(`externalOrigins: at most ${MAX_ORIGINS} origins (or "*" for any).`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
errors.push(...validateFunctionsManifest(m));
|
|
271
|
+
errors.push(...validateSystemParameters(m));
|
|
272
|
+
errors.push(...validateLambdas(m));
|
|
273
|
+
if (m.template !== undefined && !TEMPLATE_NAMES.includes(m.template)) {
|
|
274
|
+
errors.push(`template must be one of ${TEMPLATE_NAMES.join(', ')} (the template the app was created from; \`hawk-app upgrade\` uses it).`);
|
|
275
|
+
}
|
|
276
|
+
errors.push(...validateSchedules(m));
|
|
277
|
+
errors.push(...validateViews(m));
|
|
278
|
+
errors.push(...validateConfig(m));
|
|
279
|
+
const store = validateStore(m);
|
|
280
|
+
errors.push(...store.errors);
|
|
281
|
+
warnings.push(...store.warnings);
|
|
282
|
+
warnings.push(...trialWarnings(m));
|
|
283
|
+
|
|
284
|
+
const unknownScopes = (m.scopes ?? []).filter((s) => !SCOPES.includes(s));
|
|
285
|
+
if (unknownScopes.length) warnings.push(`These scopes are not available and will not be granted: ${unknownScopes.join(', ')}.`);
|
|
286
|
+
// Status is host-controlled now: approvers release versions in HawkSearch.
|
|
287
|
+
if (m.status !== undefined) warnings.push(STATUS_IGNORED);
|
|
288
|
+
|
|
289
|
+
return { errors, warnings };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* "lambdas": Lambdas outside Hawk Apps the app calls (scope lambdas:invoke). A list of up to 10 unique Lambda function
|
|
294
|
+
* names; the list needs the scope and the scope needs a non-empty list. Whether a platform allows a name is only known
|
|
295
|
+
* when the app calls it.
|
|
296
|
+
*/
|
|
297
|
+
export function validateLambdas(m) {
|
|
298
|
+
const errors = [];
|
|
299
|
+
const hasScope = Array.isArray(m.scopes) && m.scopes.includes(LAMBDAS_SCOPE);
|
|
300
|
+
const list = m.lambdas;
|
|
301
|
+
if (list === undefined) {
|
|
302
|
+
if (hasScope) errors.push(`The "${LAMBDAS_SCOPE}" scope needs "lambdas": the Lambdas the app calls, e.g. ["analytics-client-setup"].`);
|
|
303
|
+
return errors;
|
|
304
|
+
}
|
|
305
|
+
if (!Array.isArray(list) || list.some((name) => typeof name !== 'string')) {
|
|
306
|
+
errors.push('lambdas must be a list of Lambda function names, e.g. ["analytics-client-setup"].');
|
|
307
|
+
return errors;
|
|
308
|
+
}
|
|
309
|
+
const bad = list.filter((name) => !LAMBDA_NAME.test(name));
|
|
310
|
+
if (bad.length) errors.push(`lambdas: ${bad.map((name) => JSON.stringify(name)).join(', ')} must be a Lambda function name: 1-64 letters, digits, "-" or "_".`);
|
|
311
|
+
const duplicates = [...new Set(list.filter((name, i) => list.indexOf(name) !== i))];
|
|
312
|
+
if (duplicates.length) errors.push(`lambdas lists ${duplicates.join(', ')} more than once.`);
|
|
313
|
+
if (list.length > MAX_LAMBDAS) errors.push(`lambdas: at most ${MAX_LAMBDAS} names (found ${list.length}).`);
|
|
314
|
+
if (list.length && !hasScope) errors.push(`lambdas need the "${LAMBDAS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
315
|
+
if (!list.length && hasScope) errors.push(`The "${LAMBDAS_SCOPE}" scope needs at least one name in "lambdas".`);
|
|
316
|
+
return errors;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* "systemParameters": the HawkSearch system parameters the app may set (scope hawksearch:sysparams:write). A list of
|
|
321
|
+
* up to 50 unique names (case-insensitive); the list needs the scope and the scope needs a non-empty list.
|
|
322
|
+
*/
|
|
323
|
+
export function validateSystemParameters(m) {
|
|
324
|
+
const errors = [];
|
|
325
|
+
const hasScope = Array.isArray(m.scopes) && m.scopes.includes(SYSPARAMS_SCOPE);
|
|
326
|
+
const list = m.systemParameters;
|
|
327
|
+
if (list === undefined) {
|
|
328
|
+
if (hasScope) errors.push(`The "${SYSPARAMS_SCOPE}" scope needs "systemParameters": the system parameters the app may set, e.g. ["Timezone"].`);
|
|
329
|
+
return errors;
|
|
330
|
+
}
|
|
331
|
+
if (!Array.isArray(list) || list.some((name) => typeof name !== 'string')) {
|
|
332
|
+
errors.push('systemParameters must be a list of HawkSearch system parameter names, e.g. ["Timezone", "SearchPreviewURL"].');
|
|
333
|
+
return errors;
|
|
334
|
+
}
|
|
335
|
+
const bad = list.filter((name) => !SYSPARAM_NAME.test(name));
|
|
336
|
+
if (bad.length) {
|
|
337
|
+
errors.push(`systemParameters: ${bad.map((name) => JSON.stringify(name)).join(', ')} must be 1-50 letters, digits, "_", "." or "-".`);
|
|
338
|
+
}
|
|
339
|
+
const lower = list.map((name) => name.toLowerCase());
|
|
340
|
+
const duplicates = [...new Set(list.filter((name, i) => lower.indexOf(name.toLowerCase()) !== i))];
|
|
341
|
+
if (duplicates.length) errors.push(`systemParameters lists ${duplicates.join(', ')} more than once (names are not case-sensitive).`);
|
|
342
|
+
if (list.length > MAX_SYSPARAMS) errors.push(`systemParameters: at most ${MAX_SYSPARAMS} names (found ${list.length}).`);
|
|
343
|
+
if (list.length && !hasScope) errors.push(`systemParameters need the "${SYSPARAMS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
344
|
+
if (!list.length && hasScope) errors.push(`The "${SYSPARAMS_SCOPE}" scope needs at least one name in "systemParameters".`);
|
|
345
|
+
return errors;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* "schedules": recurring runs of the app's job functions, { "<name>": { "function", "cron", "timezone"? } }. Up to 5;
|
|
350
|
+
* each runs a function of this manifest with "job": true, on a 6-field EventBridge cron
|
|
351
|
+
* ("minutes hours day-of-month month day-of-week year") at most hourly: the minutes field is a single number.
|
|
352
|
+
*/
|
|
353
|
+
export function validateSchedules(m) {
|
|
354
|
+
const errors = [];
|
|
355
|
+
const schedules = m.schedules;
|
|
356
|
+
if (schedules === undefined) return errors;
|
|
357
|
+
if (!schedules || typeof schedules !== 'object' || Array.isArray(schedules)) {
|
|
358
|
+
errors.push('schedules must be an object: { "<name>": { "function": "<job function>", "cron": "0 7 ? * MON *" } }.');
|
|
359
|
+
return errors;
|
|
360
|
+
}
|
|
361
|
+
const names = Object.keys(schedules);
|
|
362
|
+
if (names.length > MAX_SCHEDULES) errors.push(`schedules: at most ${MAX_SCHEDULES} schedules per app (found ${names.length}).`);
|
|
363
|
+
const functions = m.functions && typeof m.functions === 'object' && !Array.isArray(m.functions) ? m.functions : {};
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
const where = `schedules.${name}`;
|
|
366
|
+
if (!FUNCTION_NAME.test(name) || name.length > MAX_NAME_LENGTH) {
|
|
367
|
+
errors.push(`${where}: schedule names must be 1-${MAX_NAME_LENGTH} lowercase letters, digits and single dashes (e.g. "nightly").`);
|
|
368
|
+
}
|
|
369
|
+
const schedule = schedules[name];
|
|
370
|
+
if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) {
|
|
371
|
+
errors.push(`${where} must be an object, e.g. { "function": "nightly-report", "cron": "0 7 ? * MON *", "timezone": "America/Chicago" }.`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const unknown = Object.keys(schedule).filter((key) => !['function', 'cron', 'timezone'].includes(key));
|
|
375
|
+
if (unknown.length) errors.push(`${where}: unknown setting${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')} (allowed: function, cron, timezone).`);
|
|
376
|
+
if (typeof schedule.function !== 'string' || !schedule.function) {
|
|
377
|
+
errors.push(`${where}.function is required: the name of a job function under "functions".`);
|
|
378
|
+
} else if (!Object.hasOwn(functions, schedule.function)) {
|
|
379
|
+
errors.push(`${where}.function: "${schedule.function}" is not declared under "functions".`);
|
|
380
|
+
} else if (functions[schedule.function]?.job !== true) {
|
|
381
|
+
errors.push(`${where}.function: "${schedule.function}" must be a job function: set "job": true on it.`);
|
|
382
|
+
}
|
|
383
|
+
if (typeof schedule.cron !== 'string') {
|
|
384
|
+
errors.push(`${where}.cron is required: an EventBridge cron like "0 7 ? * MON *" (minutes hours day-of-month month day-of-week year).`);
|
|
385
|
+
} else {
|
|
386
|
+
const fields = schedule.cron.trim().split(/\s+/);
|
|
387
|
+
if (/^cron\(/i.test(schedule.cron.trim())) {
|
|
388
|
+
errors.push(`${where}.cron: leave out the cron( ) wrapper, e.g. "0 7 ? * MON *".`);
|
|
389
|
+
} else if (fields.length !== 6) {
|
|
390
|
+
errors.push(`${where}.cron must have 6 fields: minutes hours day-of-month month day-of-week year, e.g. "0 7 ? * MON *" (found ${fields.length}).`);
|
|
391
|
+
} else if (fields.some((field) => !CRON_FIELD.test(field))) {
|
|
392
|
+
errors.push(`${where}.cron: each field may only use digits, letters and * ? , / # - (found "${schedule.cron}").`);
|
|
393
|
+
} else if (!CRON_MINUTES.test(fields[0])) {
|
|
394
|
+
errors.push(`${where}.cron: schedules run at most hourly, so the minutes field must be a single number from 0 to 59 (found "${fields[0]}").`);
|
|
395
|
+
} else if (fields[2] !== '?' && fields[4] !== '?') {
|
|
396
|
+
errors.push(`${where}.cron: day-of-month or day-of-week must be "?" (EventBridge needs one of them), e.g. "0 7 ? * MON *" or "0 7 1 * ? *".`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (schedule.timezone !== undefined && (typeof schedule.timezone !== 'string' || !TIMEZONE.test(schedule.timezone))) {
|
|
400
|
+
errors.push(`${where}.timezone must be an IANA time zone such as "America/Chicago", or "UTC" (the default).`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (names.length && !(m.scopes ?? []).includes(FUNCTIONS_SCOPE)) {
|
|
404
|
+
errors.push(`schedules need the "${FUNCTIONS_SCOPE}" scope: add it to "scopes" in hawk-app.json.`);
|
|
405
|
+
}
|
|
406
|
+
return errors;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function newManifest({ slug, name, template }) {
|
|
410
|
+
return {
|
|
411
|
+
$schema: './node_modules/@bridgeline-digital/hawk-app-cli/schema/hawk-app.schema.json',
|
|
412
|
+
slug,
|
|
413
|
+
// The template it came from: `hawk-app upgrade` keeps the app in step with it.
|
|
414
|
+
...(template ? { template } : {}),
|
|
415
|
+
name,
|
|
416
|
+
description: '',
|
|
417
|
+
// An .svg (drawn in the host's color) or .png file next to this one; create and init add a default icon.svg.
|
|
418
|
+
icon: 'icon.svg',
|
|
419
|
+
visibility: 'allowList',
|
|
420
|
+
scopes: ['reports:read'],
|
|
421
|
+
// Outside origins the app may load scripts, styles and images from and call (e.g. "https://api.example.com",
|
|
422
|
+
// or "img:*" for images from anywhere). The vanilla template loads the bridge from jsDelivr.
|
|
423
|
+
externalOrigins: template === 'vanilla' ? ['https://cdn.jsdelivr.net'] : [],
|
|
424
|
+
};
|
|
425
|
+
}
|