@entrinsik/vite-plugin-informer 2.4.0 → 2.5.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/bin/init.js +55 -27
- package/package.json +1 -1
- package/src/agent-dev.js +22 -9
- package/src/deploy.js +2 -15
- package/src/dev-dependencies.js +363 -0
- package/src/index.js +14 -0
- package/src/server-routes.js +56 -12
package/bin/init.js
CHANGED
|
@@ -13,40 +13,66 @@ const cwd = process.cwd();
|
|
|
13
13
|
function generateInformerYaml() {
|
|
14
14
|
return `# Informer App Configuration
|
|
15
15
|
# =========================
|
|
16
|
-
# This file controls
|
|
17
|
-
#
|
|
16
|
+
# This file controls the data your app depends on and defines custom
|
|
17
|
+
# roles for role-based UIs.
|
|
18
18
|
#
|
|
19
|
-
# Without
|
|
19
|
+
# Without dependencies: or access:, all API access is blocked (secure
|
|
20
|
+
# by default).
|
|
20
21
|
|
|
21
22
|
# ============================================================================
|
|
22
|
-
#
|
|
23
|
+
# DEPENDENCIES (preferred)
|
|
23
24
|
# ============================================================================
|
|
24
|
-
#
|
|
25
|
+
# Typed slots that the installer binds to actual resources at deploy
|
|
26
|
+
# time. Each slot becomes a property on the handler context object —
|
|
27
|
+
# call methods on it instead of building raw API URLs.
|
|
25
28
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# region: $user.custom.region
|
|
29
|
+
# Slot methods by target:
|
|
30
|
+
# dataset → context.<slot>.search(esQuery) / context.<slot>.fields()
|
|
31
|
+
# query → context.<slot>.execute(params)
|
|
32
|
+
# datasource → context.<slot>.query(payload)
|
|
33
|
+
# integration → context.<slot>.request({ method, path, body })
|
|
32
34
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
+
# defaultBinding must be a UUID (not a configId). Look up UUIDs via:
|
|
36
|
+
# GET /api/datasets-list → for target: dataset
|
|
37
|
+
# GET /api/queries-list → for target: query
|
|
38
|
+
# GET /api/datasources-list → for target: datasource
|
|
39
|
+
# GET /api/integrations-list → for target: integration
|
|
35
40
|
#
|
|
36
|
-
#
|
|
37
|
-
# - salesforce
|
|
41
|
+
# Example:
|
|
38
42
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
43
|
+
# dependencies:
|
|
44
|
+
# sales:
|
|
45
|
+
# target: dataset
|
|
46
|
+
# defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
|
|
47
|
+
# orders:
|
|
48
|
+
# target: dataset
|
|
49
|
+
# defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
|
|
50
|
+
# options:
|
|
51
|
+
# filter:
|
|
52
|
+
# region: $user.custom.region # Row-level security
|
|
53
|
+
# monthly_summary:
|
|
54
|
+
# target: query
|
|
55
|
+
# defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
|
|
56
|
+
# salesforce:
|
|
57
|
+
# target: integration # No defaultBinding — installer picks
|
|
58
|
+
# options:
|
|
59
|
+
# headers:
|
|
60
|
+
# Authorization: Bearer $user.custom.sfToken
|
|
61
|
+
|
|
62
|
+
dependencies: {}
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# ACCESS (raw API allowlist — for paths that don't fit the typed-slot model)
|
|
66
|
+
# ============================================================================
|
|
67
|
+
# Use access.apis for endpoints not covered by dependencies: slots,
|
|
68
|
+
# such as AI model routes or custom server endpoints.
|
|
41
69
|
#
|
|
42
|
-
#
|
|
70
|
+
# access:
|
|
71
|
+
# apis:
|
|
72
|
+
# - POST /api/models/go_everyday/_object
|
|
73
|
+
# - POST /api/models/go_everyday/_chat
|
|
43
74
|
# - POST /api/custom/endpoint
|
|
44
75
|
|
|
45
|
-
access:
|
|
46
|
-
datasets: []
|
|
47
|
-
queries: []
|
|
48
|
-
integrations: []
|
|
49
|
-
|
|
50
76
|
# ============================================================================
|
|
51
77
|
# ROLES (optional)
|
|
52
78
|
# ============================================================================
|
|
@@ -111,7 +137,8 @@ async function init() {
|
|
|
111
137
|
const pkgPath = resolve(cwd, 'package.json');
|
|
112
138
|
if (!await exists(pkgPath)) {
|
|
113
139
|
console.error('No package.json found. Run this in a Vite project directory.');
|
|
114
|
-
console.error('Create one first with: npm create vite@latest');
|
|
140
|
+
console.error('Create one first with: npm create vite@latest . -- --template react');
|
|
141
|
+
console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
|
|
115
142
|
process.exit(1);
|
|
116
143
|
}
|
|
117
144
|
|
|
@@ -121,7 +148,8 @@ async function init() {
|
|
|
121
148
|
const hasVite = pkg.devDependencies?.vite || pkg.dependencies?.vite;
|
|
122
149
|
if (!hasVite) {
|
|
123
150
|
console.error('Vite not found in dependencies.');
|
|
124
|
-
console.error('Create a Vite project first: npm create vite@latest');
|
|
151
|
+
console.error('Create a Vite project first: npm create vite@latest . -- --template react');
|
|
152
|
+
console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
|
|
125
153
|
process.exit(1);
|
|
126
154
|
}
|
|
127
155
|
|
|
@@ -198,7 +226,7 @@ INFORMER_API_KEY=your-api-key
|
|
|
198
226
|
const informerYamlPath = resolve(cwd, 'informer.yaml');
|
|
199
227
|
if (!await exists(informerYamlPath)) {
|
|
200
228
|
await writeFile(informerYamlPath, generateInformerYaml());
|
|
201
|
-
console.log('Created informer.yaml (
|
|
229
|
+
console.log('Created informer.yaml (declare your data dependencies and roles)');
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
// 10. Add .env to .gitignore if not present
|
|
@@ -207,7 +235,7 @@ INFORMER_API_KEY=your-api-key
|
|
|
207
235
|
console.log('\nSetup complete!\n');
|
|
208
236
|
console.log('Next steps:');
|
|
209
237
|
console.log(' 1. Update .env with your Informer credentials');
|
|
210
|
-
console.log(' 2.
|
|
238
|
+
console.log(' 2. Declare your data dependencies in informer.yaml (see comments inside)');
|
|
211
239
|
console.log(' 3. Run: npm install');
|
|
212
240
|
console.log(' 4. Run: npm run dev');
|
|
213
241
|
console.log('');
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createHmac } from 'node:crypto';
|
|
2
1
|
import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { parse as parseUrl } from 'node:url';
|
|
5
4
|
import yaml from 'yaml';
|
|
5
|
+
import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
6
|
const parseYaml = yaml.parse;
|
|
7
7
|
|
|
8
8
|
const MAX_STEPS = 20;
|
|
@@ -171,7 +171,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
171
171
|
// fetch() — proxies API calls to the Informer server (same as server-routes.js)
|
|
172
172
|
async function apiFetch(path, opts = {}) {
|
|
173
173
|
const method = (opts.method || 'GET').toUpperCase();
|
|
174
|
-
|
|
174
|
+
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
|
|
175
|
+
const apiPath = normalizeFetchPath(path);
|
|
176
|
+
if (!apiPath) {
|
|
177
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
178
|
+
}
|
|
179
|
+
const url = `${serverOrigin}${apiPath}`;
|
|
175
180
|
const fetchOpts = {
|
|
176
181
|
method,
|
|
177
182
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
@@ -193,12 +198,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
193
198
|
return { ok: true };
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
// notify/email — delivery is a console-logged no-op in dev, but required-field
|
|
202
|
+
// validation mirrors prod so a tool that passes here won't 500 in production.
|
|
203
|
+
const { notify, email } = buildDevMessaging('[agent-dev]');
|
|
204
|
+
|
|
205
|
+
// crypto helper — mirrors the prod sandbox crypto surface
|
|
206
|
+
const cryptoHelper = buildDevCrypto();
|
|
202
207
|
|
|
203
208
|
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
204
209
|
const logCall = (level, message, data) => {
|
|
@@ -277,6 +282,14 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
277
282
|
const instructions = agentDef.instructions || '';
|
|
278
283
|
const agentToolNames = agentDef.tools || [];
|
|
279
284
|
|
|
285
|
+
// Build the typed dependency context + env the same way the
|
|
286
|
+
// server-routes dev middleware does, so tools using
|
|
287
|
+
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
288
|
+
// the prod sandbox bag.
|
|
289
|
+
const deps = await loadDependencies(projectRoot);
|
|
290
|
+
const context = buildDevContext({ deps, apiFetch });
|
|
291
|
+
const env = await loadAppEnv(projectRoot);
|
|
292
|
+
|
|
280
293
|
// Load tool handlers via ssrLoadModule
|
|
281
294
|
const localTools = await scanLocalTools(projectRoot);
|
|
282
295
|
const toolMap = new Map(localTools.map(t => [t.name, t]));
|
|
@@ -394,7 +407,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
394
407
|
|
|
395
408
|
if (tool) {
|
|
396
409
|
try {
|
|
397
|
-
result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, crypto: cryptoHelper, markdown, log,
|
|
410
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
398
411
|
} catch (err) {
|
|
399
412
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
400
413
|
result = { error: err.message };
|
package/src/deploy.js
CHANGED
|
@@ -102,22 +102,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
102
102
|
limit: 10
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
// 5. Clear existing files
|
|
105
|
+
// 5. Clear existing files in one server-side transaction
|
|
106
106
|
console.log('Clearing existing files...');
|
|
107
|
-
|
|
108
|
-
if (files && Array.isArray(files)) {
|
|
109
|
-
// Delete non-directories first
|
|
110
|
-
const nonDirs = files.filter(f => !f.directory);
|
|
111
|
-
await Promise.all(nonDirs.map(f => api.del(`${entityPath}/files/${f.id}`)));
|
|
112
|
-
|
|
113
|
-
// Then delete directories in reverse order (deepest first by path length)
|
|
114
|
-
const dirs = files
|
|
115
|
-
.filter(f => f.directory)
|
|
116
|
-
.sort((a, b) => (b.path || '').length - (a.path || '').length);
|
|
117
|
-
for (const d of dirs) {
|
|
118
|
-
await api.del(`${entityPath}/files/${d.id}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
107
|
+
await api.post(`${entityPath}/files/_clear`);
|
|
121
108
|
|
|
122
109
|
// 6. Upload dist/ contents
|
|
123
110
|
console.log(`Uploading files from ${distDir}...`);
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
|
|
6
|
+
const parseYaml = yaml.parse;
|
|
7
|
+
|
|
8
|
+
const RANDOM_BYTES_MAX = 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the dev-mode `crypto` helper. Mirrors the production host dispatcher in
|
|
12
|
+
* app-sandbox.js (cryptoRef) so handlers/tools see the same crypto surface
|
|
13
|
+
* locally. Methods are synchronous in dev (prod returns promises across the
|
|
14
|
+
* isolate boundary); `await` works either way.
|
|
15
|
+
*
|
|
16
|
+
* @returns {Object} crypto helper { hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt, decrypt, verify }
|
|
17
|
+
*/
|
|
18
|
+
export function buildDevCrypto() {
|
|
19
|
+
return {
|
|
20
|
+
hmac: (algorithm, key, data, encoding) => crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex'),
|
|
21
|
+
hash: (algorithm, data, encoding) => crypto.createHash(algorithm).update(data).digest(encoding || 'hex'),
|
|
22
|
+
randomUUID: () => crypto.randomUUID(),
|
|
23
|
+
randomBytes: (length, encoding) => {
|
|
24
|
+
const n = Math.min(Math.max(parseInt(length, 10) || 0, 1), RANDOM_BYTES_MAX);
|
|
25
|
+
return crypto.randomBytes(n).toString(encoding || 'hex');
|
|
26
|
+
},
|
|
27
|
+
timingSafeEqual: (a, b) => {
|
|
28
|
+
if (a == null || b == null) {
|
|
29
|
+
throw new Error('crypto.timingSafeEqual requires two non-null values to compare');
|
|
30
|
+
}
|
|
31
|
+
const ba = Buffer.from(String(a));
|
|
32
|
+
const bb = Buffer.from(String(b));
|
|
33
|
+
if (ba.length !== bb.length) return false;
|
|
34
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
35
|
+
},
|
|
36
|
+
verifyHmac: (algorithm, key, data, signature, encoding) => {
|
|
37
|
+
if (signature == null) {
|
|
38
|
+
throw new Error('crypto.verifyHmac requires a signature to compare against (got null/undefined)');
|
|
39
|
+
}
|
|
40
|
+
const expected = crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex');
|
|
41
|
+
const a = Buffer.from(String(signature));
|
|
42
|
+
const b = Buffer.from(expected);
|
|
43
|
+
if (a.length !== b.length) return false;
|
|
44
|
+
return crypto.timingSafeEqual(a, b);
|
|
45
|
+
},
|
|
46
|
+
encrypt: (plaintext, key) => {
|
|
47
|
+
const dk = crypto.createHash('sha256').update(String(key)).digest();
|
|
48
|
+
const iv = crypto.randomBytes(12);
|
|
49
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', dk, iv);
|
|
50
|
+
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
51
|
+
const tag = cipher.getAuthTag();
|
|
52
|
+
return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
|
|
53
|
+
},
|
|
54
|
+
decrypt: (payload, key) => {
|
|
55
|
+
const parts = String(payload).split(':');
|
|
56
|
+
if (parts.length !== 3) {
|
|
57
|
+
throw new Error('crypto.decrypt: malformed payload (expected iv:tag:ciphertext)');
|
|
58
|
+
}
|
|
59
|
+
const dk = crypto.createHash('sha256').update(String(key)).digest();
|
|
60
|
+
const iv = Buffer.from(parts[0], 'base64');
|
|
61
|
+
const tag = Buffer.from(parts[1], 'base64');
|
|
62
|
+
const ct = Buffer.from(parts[2], 'base64');
|
|
63
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', dk, iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
66
|
+
},
|
|
67
|
+
verify: (algorithm, data, signature, publicKey, signatureEncoding) => {
|
|
68
|
+
if (data == null || signature == null || publicKey == null) {
|
|
69
|
+
throw new Error('crypto.verify requires data, signature, and publicKey');
|
|
70
|
+
}
|
|
71
|
+
return crypto.verify(algorithm, Buffer.from(String(data)), publicKey, Buffer.from(String(signature), signatureEncoding || 'base64'));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Canonicalize a guest-code fetch() path the same way the prod sandbox does
|
|
78
|
+
* (see normalizeFetchPath in app-sandbox.js). Returns the normalized `/api/...`
|
|
79
|
+
* path, or null for any non-canonical input — so dev rejects the same shapes
|
|
80
|
+
* prod 400s instead of silently accepting `//api/foo` / `\api\foo`.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} urlPath
|
|
83
|
+
* @returns {string|null}
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeFetchPath(urlPath) {
|
|
86
|
+
if (typeof urlPath !== 'string' || !urlPath) return null;
|
|
87
|
+
// eslint-disable-next-line no-control-regex
|
|
88
|
+
if (/[\\\x00-\x1f\s]/.test(urlPath)) return null;
|
|
89
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(urlPath)) return null;
|
|
90
|
+
let p = urlPath;
|
|
91
|
+
if (p.startsWith('/')) p = p.slice(1);
|
|
92
|
+
if (p.startsWith('api/')) p = p.slice('api/'.length);
|
|
93
|
+
if (!p || p.startsWith('/') || p.startsWith('api/')) return null;
|
|
94
|
+
if (p.includes('//')) return null;
|
|
95
|
+
return `/api/${p}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build dev-mode notify/email shims that mirror the PROD required-field
|
|
100
|
+
* validation (app-message-utils.js) so an app that passes locally also passes in
|
|
101
|
+
* production. Delivery is still a console-logged no-op in dev.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} logPrefix - console log prefix (e.g. '[app]' or '[agent-dev]')
|
|
104
|
+
* @returns {{ notify: Function, email: Function }}
|
|
105
|
+
*/
|
|
106
|
+
export function buildDevMessaging(logPrefix = '[app]') {
|
|
107
|
+
const notify = (usernameOrArray, message) => {
|
|
108
|
+
if (Array.isArray(usernameOrArray)) {
|
|
109
|
+
for (const item of usernameOrArray) {
|
|
110
|
+
if (!item || typeof item.username !== 'string') throw new Error('notify() bulk items require a username');
|
|
111
|
+
if (!item.title) throw new Error('notify() bulk items require a title');
|
|
112
|
+
}
|
|
113
|
+
console.log(`${logPrefix} notify (bulk)`, JSON.stringify(usernameOrArray));
|
|
114
|
+
return { ids: usernameOrArray.map(() => 'dev-message'), queued: usernameOrArray.length };
|
|
115
|
+
}
|
|
116
|
+
if (!usernameOrArray || typeof usernameOrArray !== 'string') {
|
|
117
|
+
throw new Error('notify() requires a username as the first argument');
|
|
118
|
+
}
|
|
119
|
+
if (!message || !message.title) {
|
|
120
|
+
throw new Error('notify() requires a message with at least a title');
|
|
121
|
+
}
|
|
122
|
+
console.log(`${logPrefix} notify`, usernameOrArray, JSON.stringify(message));
|
|
123
|
+
return { id: 'dev-message' };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const email = (toOrArray, message) => {
|
|
127
|
+
if (Array.isArray(toOrArray)) {
|
|
128
|
+
for (const item of toOrArray) {
|
|
129
|
+
if (!item || typeof item.to !== 'string') throw new Error('email() bulk items require a to address');
|
|
130
|
+
if (!item.subject) throw new Error('email() bulk items require a subject');
|
|
131
|
+
}
|
|
132
|
+
console.log(`${logPrefix} email (bulk)`, JSON.stringify(toOrArray));
|
|
133
|
+
return { ids: toOrArray.map(() => 'dev-message'), queued: toOrArray.length };
|
|
134
|
+
}
|
|
135
|
+
if (!toOrArray || typeof toOrArray !== 'string') {
|
|
136
|
+
throw new Error('email() requires an email address as the first argument');
|
|
137
|
+
}
|
|
138
|
+
if (!message || !message.subject) {
|
|
139
|
+
throw new Error('email() requires a message with at least a subject');
|
|
140
|
+
}
|
|
141
|
+
console.log(`${logPrefix} email`, toOrArray, JSON.stringify(message));
|
|
142
|
+
return { id: 'dev-message' };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { notify, email };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mirrors the server-side patterns in
|
|
149
|
+
// packages/informer-server/modules/app/routes/deploy.js so dev and deploy
|
|
150
|
+
// reject the same shapes with the same wording. Keep these in lockstep.
|
|
151
|
+
const DEPENDENCY_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
152
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
153
|
+
const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration']);
|
|
154
|
+
const VALID_RUN_AS = new Set(['user', 'owner']);
|
|
155
|
+
|
|
156
|
+
const METHOD_SURFACE = {
|
|
157
|
+
dataset: ['search', 'fields'],
|
|
158
|
+
query: ['execute'],
|
|
159
|
+
datasource: ['query'],
|
|
160
|
+
integration: ['request']
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
|
|
165
|
+
* is missing or the section is absent — both are valid (an app may have no
|
|
166
|
+
* declared dependencies).
|
|
167
|
+
*
|
|
168
|
+
* @param {string} projectRoot
|
|
169
|
+
* @returns {Promise<Object>} The raw dependencies object as authored
|
|
170
|
+
*/
|
|
171
|
+
export async function loadDependencies(projectRoot) {
|
|
172
|
+
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
173
|
+
try {
|
|
174
|
+
await access(yamlPath);
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
179
|
+
const parsed = parseYaml(content);
|
|
180
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
181
|
+
return (parsed.dependencies && typeof parsed.dependencies === 'object') ? parsed.dependencies : {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load the app `env:` map from informer.yaml. In production the sandbox injects
|
|
186
|
+
* `app.defn.env`; dev has no app model, so the YAML's top-level `env:` block is
|
|
187
|
+
* the closest stand-in. Returns `{}` when missing so handlers/tools see the same
|
|
188
|
+
* empty object rather than undefined.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} projectRoot
|
|
191
|
+
* @returns {Promise<Object>} The raw env object as authored
|
|
192
|
+
*/
|
|
193
|
+
export async function loadAppEnv(projectRoot) {
|
|
194
|
+
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
195
|
+
try {
|
|
196
|
+
await access(yamlPath);
|
|
197
|
+
} catch {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
201
|
+
const parsed = parseYaml(content);
|
|
202
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
203
|
+
return (parsed.env && typeof parsed.env === 'object') ? parsed.env : {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Validate the shape of a parsed `dependencies:` block. Returns an array of
|
|
208
|
+
* human-readable error strings (empty when valid). Wording mirrors deploy.js
|
|
209
|
+
* so devs see the same message at boot and at deploy.
|
|
210
|
+
*
|
|
211
|
+
* Skips "is the resource actually readable?" — that requires a network call
|
|
212
|
+
* against the configured server and would slow startup. Shape validation is
|
|
213
|
+
* the cheap win; the deploy path still does the read check.
|
|
214
|
+
*
|
|
215
|
+
* @param {Object} deps - The raw `dependencies:` object
|
|
216
|
+
* @returns {string[]} Error messages, one per problem
|
|
217
|
+
*/
|
|
218
|
+
export function validateDependencies(deps) {
|
|
219
|
+
const errors = [];
|
|
220
|
+
for (const [name, decl] of Object.entries(deps || {})) {
|
|
221
|
+
if (!DEPENDENCY_NAME_PATTERN.test(name)) {
|
|
222
|
+
errors.push(`dependency "${name}": invalid name (must be lowercase dot-segmented, eg. "orders.list")`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!decl || typeof decl !== 'object') {
|
|
226
|
+
errors.push(`dependency "${name}": must be an object`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!decl.target || typeof decl.target !== 'string') {
|
|
230
|
+
errors.push(`dependency "${name}": missing required "target" field`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!VALID_TARGETS.has(decl.target)) {
|
|
234
|
+
errors.push(`dependency "${name}": unknown target "${decl.target}" (expected one of: ${[...VALID_TARGETS].join(', ')})`);
|
|
235
|
+
}
|
|
236
|
+
const runAs = decl.runAs || 'user';
|
|
237
|
+
if (!VALID_RUN_AS.has(runAs)) {
|
|
238
|
+
errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
|
|
239
|
+
}
|
|
240
|
+
if (decl.defaultBinding != null) {
|
|
241
|
+
if (typeof decl.defaultBinding !== 'string') {
|
|
242
|
+
errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
|
|
243
|
+
} else if (!UUID_PATTERN.test(decl.defaultBinding)) {
|
|
244
|
+
errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return errors;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the dev-mode `context` object passed to server route handlers, so
|
|
253
|
+
* `await context.myDep.method(...)` works the same locally as it does after
|
|
254
|
+
* deploy. Mirrors the production sandbox's typed-proxy DI context built in
|
|
255
|
+
* `app-sandbox.js`.
|
|
256
|
+
*
|
|
257
|
+
* Dev-mode differences from production:
|
|
258
|
+
* - There is no installer step locally, so each dep is auto-bound to its
|
|
259
|
+
* manifest `defaultBinding` UUID. Deps without one return an unbound
|
|
260
|
+
* proxy whose methods throw a pointed message — same shape as the
|
|
261
|
+
* prod `makeUnboundProxy` behavior, just with a dev-flavored hint.
|
|
262
|
+
* - `runAs` is informational only. Every dev call goes through the
|
|
263
|
+
* vite plugin's auth header (the same identity that handles /api proxy
|
|
264
|
+
* requests), regardless of whether the manifest declares user or owner.
|
|
265
|
+
* - Driver option-merging (dataset filters, query default parameters,
|
|
266
|
+
* integration paths/headers) is not replicated here. Dev is a thin
|
|
267
|
+
* pass-through — exotic prod semantics surface as integration-test work,
|
|
268
|
+
* not as silent dev parity.
|
|
269
|
+
*
|
|
270
|
+
* @param {Object} args
|
|
271
|
+
* @param {Object} args.deps - The raw `dependencies:` object
|
|
272
|
+
* @param {Function} args.apiFetch - The dev-server fetch helper
|
|
273
|
+
* (path, { method, body }) => { status, body }
|
|
274
|
+
* @returns {Object} An object keyed by dependency name, values are typed
|
|
275
|
+
* proxies with methods matching the target's production method surface.
|
|
276
|
+
*/
|
|
277
|
+
export function buildDevContext({ deps, apiFetch }) {
|
|
278
|
+
const context = {};
|
|
279
|
+
for (const [name, decl] of Object.entries(deps || {})) {
|
|
280
|
+
if (!decl || typeof decl !== 'object') continue;
|
|
281
|
+
const target = decl.target;
|
|
282
|
+
if (!VALID_TARGETS.has(target)) continue;
|
|
283
|
+
|
|
284
|
+
const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
285
|
+
? decl.defaultBinding
|
|
286
|
+
: null;
|
|
287
|
+
|
|
288
|
+
context[name] = targetId
|
|
289
|
+
? makeDevProxy({ name, target, targetId, apiFetch })
|
|
290
|
+
: makeUnboundDevProxy({ name, target });
|
|
291
|
+
}
|
|
292
|
+
return context;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
296
|
+
switch (target) {
|
|
297
|
+
case 'dataset':
|
|
298
|
+
return {
|
|
299
|
+
async search(payload) {
|
|
300
|
+
return await devCall(apiFetch, 'POST', `datasets/${targetId}/_search`, payload || {}, name, 'dataset');
|
|
301
|
+
},
|
|
302
|
+
async fields() {
|
|
303
|
+
return await devCall(apiFetch, 'GET', `datasets/${targetId}/fields`, null, name, 'dataset');
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
case 'query':
|
|
307
|
+
return {
|
|
308
|
+
async execute(payload) {
|
|
309
|
+
return await devCall(apiFetch, 'POST', `queries/${targetId}/_execute`, payload || {}, name, 'query');
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
case 'datasource':
|
|
313
|
+
return {
|
|
314
|
+
async query(payload) {
|
|
315
|
+
return await devCall(apiFetch, 'POST', `datasources/${targetId}/_query`, payload || {}, name, 'datasource');
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
case 'integration':
|
|
319
|
+
return {
|
|
320
|
+
async request(payload) {
|
|
321
|
+
return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
default:
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build an unbound dev proxy whose every method throws a clear error pointing
|
|
331
|
+
* at the missing `defaultBinding`. The method-surface keys are populated so
|
|
332
|
+
* a typo on the method name throws TypeError immediately, not a silent
|
|
333
|
+
* `undefined is not a function` later — matching the prod unbound-proxy
|
|
334
|
+
* contract.
|
|
335
|
+
*/
|
|
336
|
+
function makeUnboundDevProxy({ name, target }) {
|
|
337
|
+
const methods = METHOD_SURFACE[target] || [];
|
|
338
|
+
const proxy = {};
|
|
339
|
+
for (const method of methods) {
|
|
340
|
+
proxy[method] = async () => {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
|
|
343
|
+
);
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return proxy;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function devCall(apiFetch, method, path, body, depName, resourceType) {
|
|
350
|
+
const opts = { method };
|
|
351
|
+
if (body !== null && body !== undefined) opts.body = body;
|
|
352
|
+
const { status, body: responseBody } = await apiFetch(path, opts);
|
|
353
|
+
if (status >= 400) {
|
|
354
|
+
const message = responseBody && typeof responseBody === 'object' && responseBody.message
|
|
355
|
+
? responseBody.message
|
|
356
|
+
: String(status);
|
|
357
|
+
const err = new Error(`Dependency "${depName}" (${resourceType}) call failed: ${message}`);
|
|
358
|
+
err.status = status;
|
|
359
|
+
err.body = responseBody;
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
return responseBody;
|
|
363
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { createClient } from './client.js';
|
|
5
|
+
import { loadDependencies, validateDependencies } from './dev-dependencies.js';
|
|
5
6
|
import { loadEnv, envWritePath } from './env.js';
|
|
6
7
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
7
8
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
@@ -119,6 +120,19 @@ export default function informer(options = {}) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
// Surface manifest-level dependency declaration errors at boot,
|
|
124
|
+
// not at `npx informer publish` time. Matches the deploy.js
|
|
125
|
+
// validation so devs see the same wording pre-deploy.
|
|
126
|
+
try {
|
|
127
|
+
const deps = await loadDependencies(projectRoot);
|
|
128
|
+
const errors = validateDependencies(deps);
|
|
129
|
+
for (const message of errors) {
|
|
130
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn(`[informer] Could not validate informer.yaml dependencies: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
// Mount server-side route handlers if a server/ directory exists
|
|
123
137
|
const serverDir = resolve(projectRoot, 'server');
|
|
124
138
|
|
package/src/server-routes.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { createHmac } from 'node:crypto';
|
|
2
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
2
|
import { join, relative, posix } from 'node:path';
|
|
4
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
|
+
import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
5
5
|
|
|
6
6
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
7
|
|
|
8
|
+
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
9
|
+
// to keep dev and prod behavior aligned.
|
|
10
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* Convert a file path under server/ to a route path.
|
|
10
14
|
*
|
|
@@ -171,7 +175,13 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
171
175
|
// fetch() implementation — proxies API calls to the Informer server
|
|
172
176
|
async function apiFetch(path, opts = {}) {
|
|
173
177
|
const method = (opts.method || 'GET').toUpperCase();
|
|
174
|
-
|
|
178
|
+
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
179
|
+
// reject non-canonical shapes here instead of silently accepting them.
|
|
180
|
+
const apiPath = normalizeFetchPath(path);
|
|
181
|
+
if (!apiPath) {
|
|
182
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
183
|
+
}
|
|
184
|
+
const url = `${serverOrigin}${apiPath}`;
|
|
175
185
|
const fetchOpts = {
|
|
176
186
|
method,
|
|
177
187
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
@@ -230,12 +240,8 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
230
240
|
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
231
241
|
}
|
|
232
242
|
|
|
233
|
-
// crypto helper — mirrors the sandbox
|
|
234
|
-
const cryptoHelper =
|
|
235
|
-
hmac(algorithm, key, data, encoding) {
|
|
236
|
-
return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
|
|
237
|
-
}
|
|
238
|
-
};
|
|
243
|
+
// crypto helper — mirrors the prod sandbox crypto surface
|
|
244
|
+
const cryptoHelper = buildDevCrypto();
|
|
239
245
|
|
|
240
246
|
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
241
247
|
const logCall = (level, message, data) => {
|
|
@@ -263,6 +269,11 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
263
269
|
return { ok: true };
|
|
264
270
|
};
|
|
265
271
|
|
|
272
|
+
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
273
|
+
// required-field validation mirrors prod so an app that passes here
|
|
274
|
+
// won't 500 in production.
|
|
275
|
+
const { notify, email } = buildDevMessaging('[app]');
|
|
276
|
+
|
|
266
277
|
// Build request context
|
|
267
278
|
const request = {
|
|
268
279
|
method: req.method.toUpperCase(),
|
|
@@ -291,20 +302,39 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
291
302
|
res.end(JSON.stringify(earlyBody));
|
|
292
303
|
}
|
|
293
304
|
|
|
294
|
-
//
|
|
295
|
-
|
|
305
|
+
// Build the dependency-injection context the same way the prod
|
|
306
|
+
// sandbox does, so handlers using `await context.myDep.method(...)`
|
|
307
|
+
// work locally. Loaded per request so edits to informer.yaml take
|
|
308
|
+
// effect without a dev-server restart.
|
|
309
|
+
const deps = await loadDependencies(projectRoot);
|
|
310
|
+
const context = buildDevContext({ deps, apiFetch });
|
|
311
|
+
const env = await loadAppEnv(projectRoot);
|
|
312
|
+
|
|
313
|
+
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
314
|
+
const result = await handler({ request, context, query, fetch: apiFetch, respond, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
296
315
|
|
|
297
316
|
// If respond() was already called, the response is already sent
|
|
298
317
|
if (responded) return;
|
|
299
318
|
|
|
300
|
-
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
301
|
-
|
|
319
|
+
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
|
|
320
|
+
// The encoding allow-list and contract checks are enforced inside the
|
|
321
|
+
// ivm in production; replicate them here so handlers see the same
|
|
322
|
+
// errors in dev (where there's no isolate to attribute the throw to).
|
|
323
|
+
// If you change this, mirror it in modules/app/routes/view-api.js.
|
|
324
|
+
let status, responseBody, responseHeaders, encoding;
|
|
302
325
|
|
|
303
326
|
if (result === undefined || result === null) {
|
|
304
327
|
status = 204;
|
|
305
328
|
responseBody = null;
|
|
306
329
|
responseHeaders = {};
|
|
307
330
|
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
331
|
+
encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
332
|
+
if (encoding !== null && encoding !== 'base64') {
|
|
333
|
+
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
334
|
+
}
|
|
335
|
+
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
336
|
+
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
337
|
+
}
|
|
308
338
|
status = result.status || 200;
|
|
309
339
|
responseBody = result.body !== undefined ? result.body : null;
|
|
310
340
|
responseHeaders = result.headers || {};
|
|
@@ -321,6 +351,20 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
321
351
|
|
|
322
352
|
if (responseBody === null) {
|
|
323
353
|
res.end();
|
|
354
|
+
} else if (encoding === 'base64' && typeof responseBody === 'string') {
|
|
355
|
+
if (!BASE64_RE.test(responseBody)) {
|
|
356
|
+
throw new Error('Handler returned malformed base64 body');
|
|
357
|
+
}
|
|
358
|
+
res.end(Buffer.from(responseBody, 'base64'));
|
|
359
|
+
} else if (typeof responseBody === 'string') {
|
|
360
|
+
// Pre-PR the dev middleware always JSON.stringify'd the body, so
|
|
361
|
+
// a handler returning { body: 'hello' } emitted "hello" (with
|
|
362
|
+
// quotes) — diverging from prod which passed strings verbatim.
|
|
363
|
+
// This branch fixes that parity.
|
|
364
|
+
if (!res.getHeader('content-type')) {
|
|
365
|
+
res.setHeader('Content-Type', 'application/json');
|
|
366
|
+
}
|
|
367
|
+
res.end(responseBody);
|
|
324
368
|
} else {
|
|
325
369
|
if (!res.getHeader('content-type')) {
|
|
326
370
|
res.setHeader('Content-Type', 'application/json');
|