@airfone/connector 0.0.0-stage → 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 +21 -0
- package/README.md +43 -2
- package/dist/bundle.d.ts +20 -0
- package/dist/bundle.js +101 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +242 -0
- package/dist/host.d.ts +81 -0
- package/dist/host.js +219 -0
- package/dist/http.d.ts +34 -0
- package/dist/http.js +268 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/knowledge.d.ts +6 -0
- package/dist/knowledge.js +141 -0
- package/dist/manifest.d.ts +39 -0
- package/dist/manifest.js +316 -0
- package/dist/net.d.ts +20 -0
- package/dist/net.js +73 -0
- package/dist/review.d.ts +16 -0
- package/dist/review.js +103 -0
- package/dist/runner.d.ts +26 -0
- package/dist/runner.js +280 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +4 -0
- package/package.json +46 -3
package/dist/review.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The checks AirFone runs on upload (service.rs review()), run locally.
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { buildRequest } from './http.js';
|
|
5
|
+
import { checkSchema, secretNames, withConfigDefaults } from './manifest.js';
|
|
6
|
+
import { ReviewHost } from './host.js';
|
|
7
|
+
import { compileCheck, runAction } from './runner.js';
|
|
8
|
+
export function subset(expected, actual) {
|
|
9
|
+
if (expected && actual && typeof expected === 'object' && typeof actual === 'object' && !Array.isArray(expected) && !Array.isArray(actual)) {
|
|
10
|
+
return Object.entries(expected).every(([k, v]) => k in actual && subset(v, actual[k]));
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(expected) === JSON.stringify(actual);
|
|
13
|
+
}
|
|
14
|
+
/** Fixtures from the manifest, plus tests/*.json files ({ action, input, config?, expect? } or an array of them). These run locally only. */
|
|
15
|
+
export function fixtures(m, dir) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const a of m.actions)
|
|
18
|
+
(a.fixtures ?? []).forEach((f, i) => out.push({ action: a.name, label: `${a.name} fixture ${i + 1}`, input: f.input, config: f.config, expect: f.expect }));
|
|
19
|
+
const testDir = join(dir, 'tests');
|
|
20
|
+
if (existsSync(testDir)) {
|
|
21
|
+
for (const file of readdirSync(testDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
22
|
+
const data = JSON.parse(readFileSync(join(testDir, file), 'utf8'));
|
|
23
|
+
(Array.isArray(data) ? data : [data]).forEach((f, i) => out.push({ ...f, label: `tests/${file}${Array.isArray(data) ? ` #${i + 1}` : ''} (${f.action})` }));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export async function review(m, js, list) {
|
|
29
|
+
const findings = [];
|
|
30
|
+
const error = (message) => findings.push({ level: 'error', message });
|
|
31
|
+
const warning = (message) => findings.push({ level: 'warning', message });
|
|
32
|
+
if (m.sensitive)
|
|
33
|
+
warning(`sensitive: hosts in ${(m.regions ?? []).join(', ')}`);
|
|
34
|
+
if (m.actions.some((a) => a.kind === 'js') && js === null) {
|
|
35
|
+
error('the manifest has JS actions but the bundle has no index.js');
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
if (js !== null) {
|
|
39
|
+
if (Buffer.byteLength(js) > 1024 * 1024)
|
|
40
|
+
error('index.js is over 1 MB');
|
|
41
|
+
for (const banned of ['eval(', 'new Function(', 'Function("', '__proto__', 'WebAssembly'])
|
|
42
|
+
if (js.includes(banned))
|
|
43
|
+
error(`index.js uses ${banned}, which is not allowed`);
|
|
44
|
+
for (const leak of ['-----BEGIN', 'sk_live_', 'AKIA', 'AIza', 'xoxb-', 'ghp_'])
|
|
45
|
+
if (js.includes(leak))
|
|
46
|
+
error(`index.js looks like it holds a credential (${leak}…); put it in secrets`);
|
|
47
|
+
try {
|
|
48
|
+
await compileCheck(js);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
error(`index.js does not load: ${e.message}`);
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const fakeSecrets = {};
|
|
56
|
+
for (const name of secretNames(m))
|
|
57
|
+
fakeSecrets[name] = 'review';
|
|
58
|
+
for (const f of list) {
|
|
59
|
+
const action = m.actions.find((x) => x.name === f.action);
|
|
60
|
+
if (!action) {
|
|
61
|
+
error(`${f.label}: no action named ${JSON.stringify(f.action)}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const config = withConfigDefaults(m.config, f.config ?? {});
|
|
65
|
+
try {
|
|
66
|
+
checkSchema('input', action.input, f.input);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
error(`${f.label}: ${e.message}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (action.kind === 'http') {
|
|
73
|
+
try {
|
|
74
|
+
buildRequest(m, action.http, f.input, config, fakeSecrets);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
error(`${f.label}: ${e.message}`);
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (js === null)
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
const out = await runAction(js, action.js ?? action.name, f.input, config, new ReviewHost(m, config));
|
|
85
|
+
try {
|
|
86
|
+
checkSchema('output', action.output, out);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
error(`${f.label}: ${e.message}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (f.expect !== undefined && f.expect !== null && !subset(f.expect, out))
|
|
93
|
+
error(`${f.label}: expected ${JSON.stringify(f.expect)}, got ${JSON.stringify(out)}`);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
error(`${f.label}: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// AirFone only sees fixtures in manifest.json; tests/*.json stay on your machine.
|
|
100
|
+
if (!m.actions.some((x) => (x.fixtures ?? []).length > 0))
|
|
101
|
+
warning('no test fixtures: actions were checked but not run');
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type QuickJSContext } from 'quickjs-emscripten';
|
|
2
|
+
import type { OutRequest, OutResponse, EgressOptions } from './net.js';
|
|
3
|
+
export interface Limits {
|
|
4
|
+
memoryBytes: number;
|
|
5
|
+
stackBytes: number;
|
|
6
|
+
cpuMs: number;
|
|
7
|
+
wallMs: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_LIMITS: Limits;
|
|
10
|
+
/** Top-level module code at compile time (QuickJsEngine::default compile_limits). */
|
|
11
|
+
export declare const COMPILE_LIMITS: Limits;
|
|
12
|
+
/** The only way out of the sandbox. Throw a string or Error to fail the call. */
|
|
13
|
+
export interface HostBridge {
|
|
14
|
+
call(op: string, args: any, deadline: number): unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare const PRELUDE: string;
|
|
17
|
+
export declare class ScriptError extends Error {
|
|
18
|
+
}
|
|
19
|
+
/** Compiles and evaluates index.js and checks it exports `actions`, as the server does on upload. */
|
|
20
|
+
export declare function compileCheck(source: string): Promise<void>;
|
|
21
|
+
/** Runs one action to completion. Resolves to the JSON result. */
|
|
22
|
+
export declare function runAction(source: string, exportName: string, input: unknown, config: unknown, host: HostBridge, limits?: Limits): Promise<unknown>;
|
|
23
|
+
export type { QuickJSContext };
|
|
24
|
+
/** Sends an HTTP request and blocks until it answers or the deadline passes. */
|
|
25
|
+
export declare function fetchSync(allowed: string[], req: OutRequest, opts: EgressOptions, deadline: number): OutResponse;
|
|
26
|
+
export declare function stopHttpWorker(): void;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The local JS sandbox. It mirrors the server's QuickJS runtime
|
|
2
|
+
// (backend/crates/connectors/src/js.rs) so code that works here works there:
|
|
3
|
+
//
|
|
4
|
+
// - QuickJS (via quickjs-emscripten), a fresh runtime and context per call;
|
|
5
|
+
// - 64 MB heap, 512 KB stack, 2 s of CPU and 10 s of wall time per call
|
|
6
|
+
// (CPU = wall time minus time spent in host calls);
|
|
7
|
+
// - the same PRELUDE: a frozen `ctx` whose host calls are synchronous inside
|
|
8
|
+
// the sandbox. Here http.fetch blocks on a worker thread with Atomics.wait,
|
|
9
|
+
// as the server blocks its sandbox thread;
|
|
10
|
+
// - no timers, no global fetch, no filesystem.
|
|
11
|
+
import { Worker, MessageChannel, receiveMessageOnPort } from 'node:worker_threads';
|
|
12
|
+
import { getQuickJS } from 'quickjs-emscripten';
|
|
13
|
+
export const DEFAULT_LIMITS = { memoryBytes: 64 * 1024 * 1024, stackBytes: 512 * 1024, cpuMs: 2000, wallMs: 10_000 };
|
|
14
|
+
/** Top-level module code at compile time (QuickJsEngine::default compile_limits). */
|
|
15
|
+
export const COMPILE_LIMITS = { memoryBytes: 32 * 1024 * 1024, stackBytes: 512 * 1024, cpuMs: 1000, wallMs: 1000 };
|
|
16
|
+
// The ctx part of prelude.js. Keep identical.
|
|
17
|
+
export const PRELUDE = String.raw `
|
|
18
|
+
(() => {
|
|
19
|
+
const call = globalThis.__host;
|
|
20
|
+
const configJson = globalThis.__config;
|
|
21
|
+
delete globalThis.__host;
|
|
22
|
+
delete globalThis.__config;
|
|
23
|
+
const invoke = (op, args) => {
|
|
24
|
+
const r = JSON.parse(call(op, JSON.stringify(args === undefined ? null : args)));
|
|
25
|
+
if (!r.ok) throw new Error(r.error);
|
|
26
|
+
return r.value;
|
|
27
|
+
};
|
|
28
|
+
const freeze = Object.freeze;
|
|
29
|
+
const deepFreeze = (o) => {
|
|
30
|
+
if (o && typeof o === "object") { Object.values(o).forEach(deepFreeze); freeze(o); }
|
|
31
|
+
return o;
|
|
32
|
+
};
|
|
33
|
+
const fetch = (url, opts) => {
|
|
34
|
+
const o = opts || {};
|
|
35
|
+
const headers = Object.assign({}, o.headers || {});
|
|
36
|
+
let body = null;
|
|
37
|
+
if (o.json !== undefined) {
|
|
38
|
+
body = JSON.stringify(o.json);
|
|
39
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) headers["Content-Type"] = "application/json";
|
|
40
|
+
} else if (o.body !== undefined && o.body !== null) {
|
|
41
|
+
body = typeof o.body === "string" ? o.body : JSON.stringify(o.body);
|
|
42
|
+
}
|
|
43
|
+
const r = invoke("http.fetch", {
|
|
44
|
+
url: String(url), method: o.method || "GET", headers, query: o.query || {}, body,
|
|
45
|
+
});
|
|
46
|
+
let json = null;
|
|
47
|
+
try { json = r.body === "" ? null : JSON.parse(r.body); } catch (e) { json = null; }
|
|
48
|
+
return freeze({
|
|
49
|
+
status: r.status, ok: r.status >= 200 && r.status < 300, headers: freeze(r.headers),
|
|
50
|
+
text: r.body, json: deepFreeze(json),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const logAt = (level) => (message, fields) => {
|
|
54
|
+
const text = typeof message === "string" ? message : JSON.stringify(message);
|
|
55
|
+
invoke("log", { level, message: fields === undefined ? text : text + " " + JSON.stringify(fields) });
|
|
56
|
+
};
|
|
57
|
+
const log = (...parts) => {
|
|
58
|
+
invoke("log", { level: "info", message: parts.map((x) => typeof x === "string" ? x : JSON.stringify(x)).join(" ") });
|
|
59
|
+
};
|
|
60
|
+
log.info = logAt("info"); log.warn = logAt("warn"); log.error = logAt("error");
|
|
61
|
+
freeze(log);
|
|
62
|
+
Object.defineProperty(globalThis, "__ctx", { value: freeze({
|
|
63
|
+
config: deepFreeze(JSON.parse(configJson)),
|
|
64
|
+
http: freeze({ fetch }),
|
|
65
|
+
store: freeze({
|
|
66
|
+
get: (key) => invoke("store.get", { key: String(key) }),
|
|
67
|
+
set: (key, value, opts) => {
|
|
68
|
+
const args = { key: String(key), value: value === undefined ? null : value };
|
|
69
|
+
if (opts && Object.prototype.hasOwnProperty.call(opts, "ifEquals")) {
|
|
70
|
+
args.if_equals = opts.ifEquals === undefined ? null : opts.ifEquals;
|
|
71
|
+
return invoke("store.set", args);
|
|
72
|
+
}
|
|
73
|
+
invoke("store.set", args);
|
|
74
|
+
},
|
|
75
|
+
incr: (key, by) => invoke("store.incr", { key: String(key), by: by === undefined ? 1 : Number(by) }),
|
|
76
|
+
delete: (key) => { invoke("store.delete", { key: String(key) }); },
|
|
77
|
+
}),
|
|
78
|
+
records: freeze({ upsert: (records) => invoke("records.upsert", { records }) }),
|
|
79
|
+
// The workspace's knowledge (the Brain): records upserted by external_id,
|
|
80
|
+
// only with the knowledge.write scope.
|
|
81
|
+
knowledge: freeze({
|
|
82
|
+
upsert: (records) => invoke("knowledge.upsert", { records }),
|
|
83
|
+
delete: (externalId) => invoke("knowledge.delete", { external_id: String(externalId) }),
|
|
84
|
+
}),
|
|
85
|
+
crypto: freeze({
|
|
86
|
+
hmac: (alg, secretName, message, encoding) =>
|
|
87
|
+
invoke("crypto.hmac", { alg: String(alg), secret: String(secretName), message: String(message), encoding: encoding || "hex" }),
|
|
88
|
+
sha256: (message, encoding) => invoke("crypto.hash", { alg: "sha256", message: String(message), encoding: encoding || "hex" }),
|
|
89
|
+
hash: (alg, message, encoding) => invoke("crypto.hash", { alg: String(alg), message: String(message), encoding: encoding || "hex" }),
|
|
90
|
+
}),
|
|
91
|
+
log,
|
|
92
|
+
}), enumerable: false, writable: false, configurable: false });
|
|
93
|
+
})();
|
|
94
|
+
`;
|
|
95
|
+
export class ScriptError extends Error {
|
|
96
|
+
}
|
|
97
|
+
let quickjs = null;
|
|
98
|
+
async function engine() { return (quickjs ??= await getQuickJS()); }
|
|
99
|
+
const STOP_NONE = 0, STOP_CPU = 1, STOP_WALL = 2;
|
|
100
|
+
/** Compiles and evaluates index.js and checks it exports `actions`, as the server does on upload. */
|
|
101
|
+
export async function compileCheck(source) {
|
|
102
|
+
await run(source, null, null, {}, { call: () => { throw 'no host calls while the module loads'; } }, COMPILE_LIMITS);
|
|
103
|
+
}
|
|
104
|
+
/** Runs one action to completion. Resolves to the JSON result. */
|
|
105
|
+
export async function runAction(source, exportName, input, config, host, limits = DEFAULT_LIMITS) {
|
|
106
|
+
return run(source, exportName, input, config, host, limits);
|
|
107
|
+
}
|
|
108
|
+
async function run(source, exportName, input, config, host, limits) {
|
|
109
|
+
const QuickJS = await engine();
|
|
110
|
+
const rt = QuickJS.newRuntime();
|
|
111
|
+
rt.setMemoryLimit(limits.memoryBytes);
|
|
112
|
+
rt.setMaxStackSize(limits.stackBytes);
|
|
113
|
+
const start = performance.now();
|
|
114
|
+
const deadline = Date.now() + limits.wallMs;
|
|
115
|
+
let hostMs = 0;
|
|
116
|
+
let stop = STOP_NONE;
|
|
117
|
+
rt.setInterruptHandler(() => {
|
|
118
|
+
const elapsed = performance.now() - start;
|
|
119
|
+
if (elapsed >= limits.wallMs) {
|
|
120
|
+
stop = STOP_WALL;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (elapsed - hostMs >= limits.cpuMs) {
|
|
124
|
+
stop = STOP_CPU;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
const ctx = rt.newContext();
|
|
130
|
+
const handles = [];
|
|
131
|
+
const keep = (h) => { handles.push(h); return h; };
|
|
132
|
+
const fail = (raw) => {
|
|
133
|
+
if (stop === STOP_CPU)
|
|
134
|
+
return new ScriptError(`stopped: used more than ${limits.cpuMs / 1000}s of CPU`);
|
|
135
|
+
if (stop === STOP_WALL)
|
|
136
|
+
return new ScriptError(`stopped: ran longer than ${limits.wallMs / 1000}s`);
|
|
137
|
+
const detail = typeof raw === 'object' && raw && 'message' in raw ? String(raw.message) : String(raw ?? 'exception');
|
|
138
|
+
if (/out of memory/i.test(detail))
|
|
139
|
+
return new ScriptError(`stopped: used more than ${limits.memoryBytes / (1024 * 1024)} MB of memory`);
|
|
140
|
+
if (/stack overflow/i.test(detail))
|
|
141
|
+
return new ScriptError('stopped: stack overflow');
|
|
142
|
+
return new ScriptError(detail);
|
|
143
|
+
};
|
|
144
|
+
const unwrap = (r) => {
|
|
145
|
+
if (r.error) {
|
|
146
|
+
const e = ctx.dump(r.error);
|
|
147
|
+
r.error.dispose();
|
|
148
|
+
throw fail(e);
|
|
149
|
+
}
|
|
150
|
+
return keep(r.value);
|
|
151
|
+
};
|
|
152
|
+
const settle = (h) => {
|
|
153
|
+
for (;;) {
|
|
154
|
+
const state = ctx.getPromiseState(h);
|
|
155
|
+
if (state.type === 'fulfilled')
|
|
156
|
+
return state.notAPromise ? h : keep(state.value);
|
|
157
|
+
if (state.type === 'rejected') {
|
|
158
|
+
const e = ctx.dump(state.error);
|
|
159
|
+
state.error.dispose();
|
|
160
|
+
throw fail(e);
|
|
161
|
+
}
|
|
162
|
+
const jobs = rt.executePendingJobs();
|
|
163
|
+
if (jobs.error) {
|
|
164
|
+
const e = ctx.dump(jobs.error);
|
|
165
|
+
jobs.error.dispose();
|
|
166
|
+
throw fail(e);
|
|
167
|
+
}
|
|
168
|
+
if (jobs.value === 0) {
|
|
169
|
+
const again = ctx.getPromiseState(h);
|
|
170
|
+
if (again.type === 'pending')
|
|
171
|
+
throw new ScriptError('the action never finished (waiting on something that cannot happen here)');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
const bridge = keep(ctx.newFunction('__host', (opH, argsH) => {
|
|
177
|
+
const began = performance.now();
|
|
178
|
+
let reply;
|
|
179
|
+
try {
|
|
180
|
+
if (Date.now() >= deadline)
|
|
181
|
+
throw 'out of time';
|
|
182
|
+
const value = host.call(ctx.getString(opH), JSON.parse(ctx.getString(argsH)), deadline);
|
|
183
|
+
reply = { ok: true, value: value === undefined ? null : value };
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
reply = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
187
|
+
}
|
|
188
|
+
hostMs += performance.now() - began;
|
|
189
|
+
return ctx.newString(JSON.stringify(reply));
|
|
190
|
+
}));
|
|
191
|
+
ctx.setProp(ctx.global, '__host', bridge);
|
|
192
|
+
ctx.setProp(ctx.global, '__config', keep(ctx.newString(JSON.stringify(config ?? {}))));
|
|
193
|
+
unwrap(ctx.evalCode(PRELUDE, 'prelude.js'));
|
|
194
|
+
const ns = settle(unwrap(ctx.evalCode(source, 'connector', { type: 'module' })));
|
|
195
|
+
const actions = keep(ctx.getProp(ns, 'actions'));
|
|
196
|
+
if (ctx.typeof(actions) !== 'object' && ctx.typeof(actions) !== 'function')
|
|
197
|
+
throw new ScriptError('index.js must `export const actions = { ... }`');
|
|
198
|
+
if (exportName === null)
|
|
199
|
+
return null;
|
|
200
|
+
const fn = keep(ctx.getProp(actions, exportName));
|
|
201
|
+
if (ctx.typeof(fn) !== 'function')
|
|
202
|
+
throw new ScriptError(`index.js does not export action ${JSON.stringify(exportName)}`);
|
|
203
|
+
ctx.setProp(ctx.global, '__input', keep(ctx.newString(JSON.stringify(input ?? null))));
|
|
204
|
+
const inputH = unwrap(ctx.evalCode('JSON.parse(globalThis.__input)', 'input.js'));
|
|
205
|
+
const ctxH = keep(ctx.getProp(ctx.global, '__ctx'));
|
|
206
|
+
const ret = settle(unwrap(ctx.callFunction(fn, ctx.undefined, inputH, ctxH)));
|
|
207
|
+
if (ctx.typeof(ret) === 'undefined')
|
|
208
|
+
return null;
|
|
209
|
+
ctx.setProp(ctx.global, '__ret', ret);
|
|
210
|
+
const text = unwrap(ctx.evalCode('JSON.stringify(globalThis.__ret)', 'ret.js'));
|
|
211
|
+
const s = ctx.typeof(text) === 'string' ? ctx.getString(text) : 'null';
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(s);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
throw new ScriptError(`the action returned non-JSON: ${e.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
if (e instanceof ScriptError)
|
|
221
|
+
throw e;
|
|
222
|
+
throw fail(e);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
for (const h of handles.reverse()) {
|
|
226
|
+
try {
|
|
227
|
+
if (h.alive)
|
|
228
|
+
h.dispose();
|
|
229
|
+
}
|
|
230
|
+
catch { /* already gone */ }
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
ctx.dispose();
|
|
234
|
+
}
|
|
235
|
+
catch { /* leaked handle after a stop */ }
|
|
236
|
+
try {
|
|
237
|
+
rt.dispose();
|
|
238
|
+
}
|
|
239
|
+
catch { /* same */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ---- Synchronous HTTP for the sandbox ----------------------------------
|
|
243
|
+
const WORKER = `
|
|
244
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
245
|
+
import(workerData.net).then(({ send }) => {
|
|
246
|
+
parentPort.on('message', async ({ port, allowed, req, opts, flag }) => {
|
|
247
|
+
let msg;
|
|
248
|
+
try { msg = { ok: true, value: await send(allowed, req, opts) }; } catch (e) { msg = { ok: false, error: e.message }; }
|
|
249
|
+
port.postMessage(msg);
|
|
250
|
+
const i32 = new Int32Array(flag);
|
|
251
|
+
Atomics.store(i32, 0, 1);
|
|
252
|
+
Atomics.notify(i32, 0);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
`;
|
|
256
|
+
let worker = null;
|
|
257
|
+
function httpWorker() {
|
|
258
|
+
if (!worker) {
|
|
259
|
+
worker = new Worker(WORKER, { eval: true, workerData: { net: new URL('./net.js', import.meta.url).href } });
|
|
260
|
+
worker.unref();
|
|
261
|
+
}
|
|
262
|
+
return worker;
|
|
263
|
+
}
|
|
264
|
+
/** Sends an HTTP request and blocks until it answers or the deadline passes. */
|
|
265
|
+
export function fetchSync(allowed, req, opts, deadline) {
|
|
266
|
+
const flag = new SharedArrayBuffer(4);
|
|
267
|
+
const { port1, port2 } = new MessageChannel();
|
|
268
|
+
httpWorker().postMessage({ port: port2, allowed, req, opts, flag }, [port2]);
|
|
269
|
+
const left = deadline - Date.now();
|
|
270
|
+
const r = Atomics.wait(new Int32Array(flag), 0, 0, Math.max(0, left));
|
|
271
|
+
const got = receiveMessageOnPort(port1);
|
|
272
|
+
port1.close();
|
|
273
|
+
if (r === 'timed-out' || !got)
|
|
274
|
+
throw new Error('out of time');
|
|
275
|
+
const msg = got.message;
|
|
276
|
+
if (!msg.ok)
|
|
277
|
+
throw new Error(msg.error);
|
|
278
|
+
return msg.value;
|
|
279
|
+
}
|
|
280
|
+
export function stopHttpWorker() { void worker?.terminate(); worker = null; }
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/** A JSON Schema object (draft 2020-12). */
|
|
2
|
+
export type JsonSchema = {
|
|
3
|
+
[key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
6
|
+
[key: string]: Json;
|
|
7
|
+
};
|
|
8
|
+
export type Scope = 'records.read' | 'records.write' | 'contacts.read' | 'messages.send';
|
|
9
|
+
export type Risk = 'read' | 'change' | 'money';
|
|
10
|
+
export type Confirm = 'none' | 'caller' | 'staff';
|
|
11
|
+
/**
|
|
12
|
+
* How AirFone authenticates to your hosts. Secrets come from the install's
|
|
13
|
+
* encrypted secrets and are added by AirFone to every request to a declared
|
|
14
|
+
* host. Your code never sees them.
|
|
15
|
+
*
|
|
16
|
+
* `type` and `kind` are the same field; `type` is the documented spelling.
|
|
17
|
+
*/
|
|
18
|
+
export type Auth = {
|
|
19
|
+
type?: 'none';
|
|
20
|
+
kind?: 'none';
|
|
21
|
+
} | {
|
|
22
|
+
type?: 'api_key';
|
|
23
|
+
kind?: 'api_key';
|
|
24
|
+
header?: string;
|
|
25
|
+
query?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
in?: 'header' | 'query';
|
|
28
|
+
secret?: string;
|
|
29
|
+
prefix?: string;
|
|
30
|
+
} | {
|
|
31
|
+
type?: 'bearer';
|
|
32
|
+
kind?: 'bearer';
|
|
33
|
+
secret?: string;
|
|
34
|
+
} | {
|
|
35
|
+
type?: 'basic';
|
|
36
|
+
kind?: 'basic';
|
|
37
|
+
username?: string;
|
|
38
|
+
password?: string;
|
|
39
|
+
} | {
|
|
40
|
+
type?: 'oauth';
|
|
41
|
+
kind?: 'oauth';
|
|
42
|
+
secret?: string;
|
|
43
|
+
scopes?: string[];
|
|
44
|
+
authorize_url?: string;
|
|
45
|
+
token_url?: string;
|
|
46
|
+
prefix?: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A declarative HTTP request. Strings are templates: `{{input.x}}`, `{{config.y}}`;
|
|
50
|
+
* `{{secrets.z}}` only in headers and query. `{{a ?? b}}` takes the first value that is
|
|
51
|
+
* not missing, null or "". Filters: `{{x | base64}}`, `urlencode`, `sha256`,
|
|
52
|
+
* `hmac_sha256(secret_name)` (hex), `hmac_sha256_b64(secret_name)`.
|
|
53
|
+
*/
|
|
54
|
+
export interface HttpSpec {
|
|
55
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | Lowercase<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>;
|
|
56
|
+
/** `https://<declared host>/...` or `{{config.<field>}}/...`. */
|
|
57
|
+
url: string;
|
|
58
|
+
query?: Record<string, string>;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
/** A JSON value whose strings are templates. A string that is exactly one `{{...}}` keeps the value's JSON type. */
|
|
61
|
+
body?: Json;
|
|
62
|
+
/** Output field to JSONPath into the response body. Empty: the whole body. `map` is accepted too. */
|
|
63
|
+
output?: Record<string, string>;
|
|
64
|
+
map?: Record<string, string>;
|
|
65
|
+
/** HTTP status to a message the assistant can say. */
|
|
66
|
+
errors?: Record<string, string>;
|
|
67
|
+
/** Fail a 2xx answer whose JSON matches. Checked in order. */
|
|
68
|
+
fail_when?: FailRule[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fails the call when `path` (JSONPath) finds `equals`, or with no `equals`
|
|
72
|
+
* when it finds any non-null value. The message is `message`, else the value
|
|
73
|
+
* at `message_path`, else a generic one.
|
|
74
|
+
*/
|
|
75
|
+
export interface FailRule {
|
|
76
|
+
path: string;
|
|
77
|
+
equals?: Json;
|
|
78
|
+
message?: string;
|
|
79
|
+
message_path?: string;
|
|
80
|
+
}
|
|
81
|
+
/** An extra install secret, for signing keys and the like. */
|
|
82
|
+
export interface SecretSpec {
|
|
83
|
+
/** Letters, digits and _. */
|
|
84
|
+
name: string;
|
|
85
|
+
title?: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
}
|
|
88
|
+
/** A recorded call used by `airfone connector test` and by AirFone's review. */
|
|
89
|
+
export interface Fixture {
|
|
90
|
+
input: Json;
|
|
91
|
+
config?: Json;
|
|
92
|
+
/** Subset match against the output. */
|
|
93
|
+
expect?: Json;
|
|
94
|
+
}
|
|
95
|
+
export interface Action {
|
|
96
|
+
/** Lowercase words joined by dots, like `order.create`. */
|
|
97
|
+
name: string;
|
|
98
|
+
/** Shown to the AI. */
|
|
99
|
+
description: string;
|
|
100
|
+
input: JsonSchema;
|
|
101
|
+
output: JsonSchema;
|
|
102
|
+
risk: Risk;
|
|
103
|
+
/** Required (`caller` or `staff`) when risk is `money`. */
|
|
104
|
+
confirm?: Confirm;
|
|
105
|
+
/** Inferred from `http` or `js` when left out. */
|
|
106
|
+
kind?: 'http' | 'js';
|
|
107
|
+
http?: HttpSpec;
|
|
108
|
+
/** For a JS action: the export in `actions` to call. Defaults to `name`. */
|
|
109
|
+
js?: string;
|
|
110
|
+
fixtures?: Fixture[];
|
|
111
|
+
/** Seconds a successful run may answer the same input again (pure reads only, 0 to 300; default 0). */
|
|
112
|
+
cache_seconds?: number;
|
|
113
|
+
}
|
|
114
|
+
export interface Manifest {
|
|
115
|
+
/** Lowercase reverse DNS, like `np.daraz.seller`. */
|
|
116
|
+
id: string;
|
|
117
|
+
/** Semver. */
|
|
118
|
+
version: string;
|
|
119
|
+
name: string;
|
|
120
|
+
description?: string;
|
|
121
|
+
category?: string;
|
|
122
|
+
sensitive?: boolean;
|
|
123
|
+
/** Countries your hosts are in. Required when sensitive. */
|
|
124
|
+
regions?: string[];
|
|
125
|
+
auth?: Auth;
|
|
126
|
+
/** Secrets beyond the ones `auth` names. */
|
|
127
|
+
secrets?: SecretSpec[];
|
|
128
|
+
/** JSON Schema for the install's settings form. A property's `default` fills a missing value. */
|
|
129
|
+
config?: JsonSchema;
|
|
130
|
+
permissions?: {
|
|
131
|
+
/** `api.example.com`, `*.example.com`, or `{{config.store_url}}`. */
|
|
132
|
+
hosts?: string[];
|
|
133
|
+
/** `records:write` is accepted for `records.write`. */
|
|
134
|
+
scopes?: (Scope | 'records:read' | 'records:write' | 'contacts:read' | 'messages:send')[];
|
|
135
|
+
};
|
|
136
|
+
actions: Action[];
|
|
137
|
+
knowledge?: {
|
|
138
|
+
name: string;
|
|
139
|
+
description?: string;
|
|
140
|
+
action: string;
|
|
141
|
+
schedule?: string;
|
|
142
|
+
}[];
|
|
143
|
+
triggers?: {
|
|
144
|
+
name: string;
|
|
145
|
+
description?: string;
|
|
146
|
+
source: 'webhook' | 'schedule';
|
|
147
|
+
}[];
|
|
148
|
+
ui?: {
|
|
149
|
+
/** Stable id the host routes by. */
|
|
150
|
+
name: string;
|
|
151
|
+
title?: string;
|
|
152
|
+
placement: 'main' | 'contact' | 'inbox';
|
|
153
|
+
/** Path under ui/. */
|
|
154
|
+
entry: string;
|
|
155
|
+
/** Bridge calls the panel may make. */
|
|
156
|
+
bridge_apis?: ('auth.session' | 'ui.toast' | 'ui.resize' | 'nav.open' | 'contact.current')[];
|
|
157
|
+
}[];
|
|
158
|
+
}
|
|
159
|
+
export interface FetchOptions {
|
|
160
|
+
method?: string;
|
|
161
|
+
headers?: Record<string, string>;
|
|
162
|
+
query?: Record<string, string | number | boolean>;
|
|
163
|
+
/** Sent as JSON with `Content-Type: application/json`. */
|
|
164
|
+
json?: Json;
|
|
165
|
+
/** A string, or a value that is JSON encoded. */
|
|
166
|
+
body?: string | Json;
|
|
167
|
+
}
|
|
168
|
+
export interface FetchResponse {
|
|
169
|
+
status: number;
|
|
170
|
+
ok: boolean;
|
|
171
|
+
headers: Readonly<Record<string, string>>;
|
|
172
|
+
/** The body as text. */
|
|
173
|
+
text: string;
|
|
174
|
+
/** The body parsed as JSON, or null. */
|
|
175
|
+
json: any;
|
|
176
|
+
}
|
|
177
|
+
export interface Log {
|
|
178
|
+
(...parts: unknown[]): void;
|
|
179
|
+
info(message: unknown, fields?: unknown): void;
|
|
180
|
+
warn(message: unknown, fields?: unknown): void;
|
|
181
|
+
error(message: unknown, fields?: unknown): void;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The second argument of every JS action. Host calls are synchronous inside
|
|
185
|
+
* the sandbox: they return values, not promises, so `await` works and so
|
|
186
|
+
* does plain use. There are no timers, no global `fetch` and no filesystem.
|
|
187
|
+
*/
|
|
188
|
+
export interface Ctx<Config = Record<string, any>> {
|
|
189
|
+
/** The install's settings, read only. Secrets are not included. */
|
|
190
|
+
readonly config: Readonly<Config>;
|
|
191
|
+
readonly http: {
|
|
192
|
+
fetch(url: string, options?: FetchOptions): FetchResponse;
|
|
193
|
+
};
|
|
194
|
+
/** A small key-value store per install. Keys 1 to 200 characters, values at most 64 KB. */
|
|
195
|
+
readonly store: {
|
|
196
|
+
get(key: string): Json;
|
|
197
|
+
/** `options.ttl` is accepted and not yet applied. */
|
|
198
|
+
set(key: string, value: Json, options?: {
|
|
199
|
+
ttl?: number;
|
|
200
|
+
}): void;
|
|
201
|
+
/** Sets only if the stored value is still `ifEquals` (null: absent). Returns whether it did. */
|
|
202
|
+
set(key: string, value: Json, options: {
|
|
203
|
+
ifEquals: Json;
|
|
204
|
+
ttl?: number;
|
|
205
|
+
}): boolean;
|
|
206
|
+
/** Adds `by` (default 1) in one step and returns the new number. */
|
|
207
|
+
incr(key: string, by?: number): number;
|
|
208
|
+
delete(key: string): void;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Hashes and HMACs computed by the host. The key is the install secret you
|
|
212
|
+
* name; your code sees only the result. `{{secrets.x}}` in `message` is
|
|
213
|
+
* replaced with that secret. `alg` is sha256 or sha512, `encoding` hex
|
|
214
|
+
* (default) or base64.
|
|
215
|
+
*/
|
|
216
|
+
readonly crypto: {
|
|
217
|
+
hmac(alg: 'sha256' | 'sha512', secretName: string, message: string, encoding?: 'hex' | 'base64'): string;
|
|
218
|
+
sha256(message: string, encoding?: 'hex' | 'base64'): string;
|
|
219
|
+
hash(alg: 'sha256' | 'sha512', message: string, encoding?: 'hex' | 'base64'): string;
|
|
220
|
+
};
|
|
221
|
+
/** Needs the `records.write` scope. */
|
|
222
|
+
readonly records: {
|
|
223
|
+
upsert(records: Json | Json[]): Json;
|
|
224
|
+
};
|
|
225
|
+
readonly log: Log;
|
|
226
|
+
}
|
|
227
|
+
export type ActionFn<I = any, O = any, Config = Record<string, any>> = (input: I, ctx: Ctx<Config>) => O | Promise<O>;
|
|
228
|
+
export type Actions = Record<string, ActionFn>;
|
package/dist/types.js
ADDED