@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 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 which Informer APIs your app can access and defines
17
- # custom roles for role-based UIs.
16
+ # This file controls the data your app depends on and defines custom
17
+ # roles for role-based UIs.
18
18
  #
19
- # Without an access section, all API access is blocked (secure by default).
19
+ # Without dependencies: or access:, all API access is blocked (secure
20
+ # by default).
20
21
 
21
22
  # ============================================================================
22
- # DATA ACCESS
23
+ # DEPENDENCIES (preferred)
23
24
  # ============================================================================
24
- # The access section controls which APIs the app can call when shared.
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
- # access:
27
- # datasets:
28
- # - admin:sales-data # Full dataset access
29
- # - id: admin:orders # With row-level security
30
- # filter:
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
- # queries:
34
- # - admin:monthly-summary
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
- # integrations:
37
- # - salesforce
41
+ # Example:
38
42
  #
39
- # datasources:
40
- # - postgres-main
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
- # apis: # Raw API paths (advanced)
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 (configure API access and roles)');
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. Update informer.yaml with the datasets/APIs your app needs');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "repository": {
6
6
  "type": "git",
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
- const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
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
- // crypto helper mirrors the sandbox's crypto.hmac()
197
- const cryptoHelper = {
198
- hmac(algorithm, key, data, encoding) {
199
- return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
200
- }
201
- };
201
+ // notify/emaildelivery 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, context: triggerEvent });
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
- const files = await api.get(`${entityPath}/files?start=0&end=10000`);
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
 
@@ -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
- const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
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's crypto.hmac()
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
- // Call handler
295
- const result = await handler({ query, fetch: apiFetch, respond, emit, crypto: cryptoHelper, markdown, log, env: {}, request });
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
- let status, responseBody, responseHeaders;
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');