@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// airfone knowledge push|delete|sources: keep a workspace's knowledge in sync
|
|
2
|
+
// with your own system through the /v1/knowledge push API. Records are
|
|
3
|
+
// upserted by (source, external_id); pushing the same content again is a
|
|
4
|
+
// no-op, so a nightly full push is cheap.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
export const KNOWLEDGE_HELP = `airfone knowledge <command>
|
|
7
|
+
|
|
8
|
+
Commands
|
|
9
|
+
push <source> <file> Upsert the records in a JSON array or JSON Lines file
|
|
10
|
+
into <source> (a-z, 0-9, - and _). 500 per request.
|
|
11
|
+
delete <source> <id> Delete one record by its external_id.
|
|
12
|
+
sources List your sources with record counts.
|
|
13
|
+
|
|
14
|
+
Each record: {"external_id", "kind", "name", "aliases"?, "facts": [{"relation",
|
|
15
|
+
"text", "audience"?, "volatile"?, "valid_from"?, "valid_to"?}], "links"?, "updated_at"?}
|
|
16
|
+
|
|
17
|
+
Needs AIRFONE_KEY (a secret API key with knowledge:write) or AIRFONE_TOKEN, and
|
|
18
|
+
AIRFONE_API (default https://app.airfone.app).`;
|
|
19
|
+
const BATCH = 500;
|
|
20
|
+
const RETRIES = 5;
|
|
21
|
+
function auth() {
|
|
22
|
+
const token = process.env.AIRFONE_KEY ?? process.env.AIRFONE_TOKEN;
|
|
23
|
+
if (!token)
|
|
24
|
+
throw new Error('set AIRFONE_KEY (a secret API key) or AIRFONE_TOKEN');
|
|
25
|
+
const api = (process.env.AIRFONE_API ?? 'https://app.airfone.app').replace(/\/$/, '');
|
|
26
|
+
return { api, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'Content-Type': 'application/json' } };
|
|
27
|
+
}
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
/** A request, retried on 429 and 5xx with backoff (Retry-After honoured). */
|
|
30
|
+
async function call(method, url, headers, body) {
|
|
31
|
+
for (let attempt = 0;; attempt++) {
|
|
32
|
+
let r;
|
|
33
|
+
try {
|
|
34
|
+
r = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (attempt >= RETRIES)
|
|
38
|
+
throw e;
|
|
39
|
+
await sleep(500 * 2 ** attempt);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if ((r.status === 429 || r.status >= 500) && attempt < RETRIES) {
|
|
43
|
+
const after = Number(r.headers.get('retry-after'));
|
|
44
|
+
await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const text = await r.text();
|
|
48
|
+
let json = null;
|
|
49
|
+
try {
|
|
50
|
+
json = text ? JSON.parse(text) : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
json = text;
|
|
54
|
+
}
|
|
55
|
+
return { status: r.status, json };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function message(json) {
|
|
59
|
+
return json?.error?.message ?? json?.message ?? (typeof json === 'string' ? json.slice(0, 300) : JSON.stringify(json));
|
|
60
|
+
}
|
|
61
|
+
/** Records from a JSON array file or a JSON Lines file. */
|
|
62
|
+
export function readRecords(path) {
|
|
63
|
+
const text = readFileSync(path, 'utf8').trim();
|
|
64
|
+
if (text.startsWith('[')) {
|
|
65
|
+
const all = JSON.parse(text);
|
|
66
|
+
if (!Array.isArray(all))
|
|
67
|
+
throw new Error(`${path}: expected an array of records`);
|
|
68
|
+
return all;
|
|
69
|
+
}
|
|
70
|
+
return text.split('\n').filter((l) => l.trim()).map((line, i) => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(line);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
throw new Error(`${path} line ${i + 1}: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function push(source, file, out, err) {
|
|
80
|
+
const { api, headers } = auth();
|
|
81
|
+
const records = readRecords(file);
|
|
82
|
+
let written = 0, unchanged = 0, failed = 0;
|
|
83
|
+
for (let i = 0; i < records.length; i += BATCH) {
|
|
84
|
+
const batch = records.slice(i, i + BATCH);
|
|
85
|
+
const { status, json } = await call('POST', `${api}/v1/knowledge/sources/${encodeURIComponent(source)}/records`, headers, { records: batch });
|
|
86
|
+
if (status !== 200)
|
|
87
|
+
throw new Error(`batch ${i / BATCH + 1} refused (${status}): ${message(json)}`);
|
|
88
|
+
written += json.written ?? 0;
|
|
89
|
+
unchanged += json.unchanged ?? 0;
|
|
90
|
+
failed += json.failed ?? 0;
|
|
91
|
+
for (const r of json.results ?? []) {
|
|
92
|
+
if (r.result === 'error')
|
|
93
|
+
err(` ${r.external_id || '(no external_id)'}: ${r.error}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
out(`${records.length} records: ${written} written, ${unchanged} unchanged, ${failed} failed`);
|
|
97
|
+
return failed === 0;
|
|
98
|
+
}
|
|
99
|
+
async function remove(source, id, out) {
|
|
100
|
+
const { api, headers } = auth();
|
|
101
|
+
const { status, json } = await call('DELETE', `${api}/v1/knowledge/sources/${encodeURIComponent(source)}/records/${encodeURIComponent(id)}`, headers);
|
|
102
|
+
if (status === 204) {
|
|
103
|
+
out(`Deleted ${id} from ${source}`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`delete failed (${status}): ${message(json)}`);
|
|
107
|
+
}
|
|
108
|
+
async function sources(out) {
|
|
109
|
+
const { api, headers } = auth();
|
|
110
|
+
const { status, json } = await call('GET', `${api}/v1/knowledge/sources`, headers);
|
|
111
|
+
if (status !== 200)
|
|
112
|
+
throw new Error(`could not list sources (${status}): ${message(json)}`);
|
|
113
|
+
if (!json.length)
|
|
114
|
+
out('No sources yet. Push records with: airfone knowledge push <source> <file>');
|
|
115
|
+
for (const s of json)
|
|
116
|
+
out(`${s.source} ${s.records} records ${s.deleted} deleted last ${s.last_received_at ?? '-'}`);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
export async function knowledge(pos, out, err) {
|
|
120
|
+
const cmd = pos.shift();
|
|
121
|
+
switch (cmd) {
|
|
122
|
+
case 'push':
|
|
123
|
+
if (pos.length < 2)
|
|
124
|
+
throw new Error('usage: airfone knowledge push <source> <file>');
|
|
125
|
+
return push(pos[0], pos[1], out, err);
|
|
126
|
+
case 'delete':
|
|
127
|
+
if (pos.length < 2)
|
|
128
|
+
throw new Error('usage: airfone knowledge delete <source> <external_id>');
|
|
129
|
+
return remove(pos[0], pos[1], out);
|
|
130
|
+
case 'sources':
|
|
131
|
+
return sources(out);
|
|
132
|
+
case undefined:
|
|
133
|
+
case 'help':
|
|
134
|
+
out(KNOWLEDGE_HELP);
|
|
135
|
+
return true;
|
|
136
|
+
default:
|
|
137
|
+
err(`Unknown command ${cmd}.\n`);
|
|
138
|
+
out(KNOWLEDGE_HELP);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Action, Manifest } from './types.js';
|
|
2
|
+
export declare const SCOPES: string[];
|
|
3
|
+
export declare class ManifestError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Canonical manifest: what the server stores after normalize(). */
|
|
6
|
+
export interface Canonical extends Omit<Manifest, 'auth' | 'actions' | 'permissions' | 'config'> {
|
|
7
|
+
auth: {
|
|
8
|
+
kind: string;
|
|
9
|
+
[k: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
config: Record<string, unknown>;
|
|
12
|
+
permissions: {
|
|
13
|
+
hosts: string[];
|
|
14
|
+
scopes: string[];
|
|
15
|
+
};
|
|
16
|
+
actions: (Action & {
|
|
17
|
+
kind: 'http' | 'js';
|
|
18
|
+
confirm: 'none' | 'caller' | 'staff';
|
|
19
|
+
})[];
|
|
20
|
+
}
|
|
21
|
+
/** Accepts the published developer format and rewrites it to the canonical one. */
|
|
22
|
+
export declare function normalize(raw: any): any;
|
|
23
|
+
export declare function validId(id: string): boolean;
|
|
24
|
+
export declare const configHostField: (e: string) => string | null;
|
|
25
|
+
export declare function validHostPattern(host: string): boolean;
|
|
26
|
+
/** Checks a value against a schema, like the server's check_schema(). */
|
|
27
|
+
export declare function checkSchema(what: string, schema: unknown, value: unknown): void;
|
|
28
|
+
/** Parses manifest.json text into the canonical manifest, or throws ManifestError. */
|
|
29
|
+
export declare function parseManifest(text: string): Canonical;
|
|
30
|
+
/** net.rs host_allowed(). */
|
|
31
|
+
export declare function hostAllowed(patterns: string[], host: string): boolean;
|
|
32
|
+
/** Every secret an install holds: the ones auth names, then `secrets` (manifest.rs secret_names). */
|
|
33
|
+
export declare function secretNames(m: Canonical): string[];
|
|
34
|
+
/** The config with each schema property's `default` filled in where missing (manifest.rs with_config_defaults). */
|
|
35
|
+
export declare function withConfigDefaults(schema: unknown, config: unknown): Record<string, unknown>;
|
|
36
|
+
/** Hosts this install may call: literal patterns plus the hosts of `{{config.x}}` URLs. */
|
|
37
|
+
export declare function allowedHosts(m: Canonical, config: any): string[];
|
|
38
|
+
/** Added hosts and scopes since a previous version (manifest.rs permission_diff). */
|
|
39
|
+
export declare function permissionDiff(next: Canonical, prev: Canonical): string[];
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// A port of backend/crates/connectors/src/manifest.rs: normalize() and
|
|
2
|
+
// validate(). Same rules, same messages, so a manifest that passes here passes
|
|
3
|
+
// the server's upload check.
|
|
4
|
+
import Ajv2020Module from 'ajv/dist/2020.js';
|
|
5
|
+
const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
|
|
6
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
7
|
+
export const SCOPES = ['records.read', 'records.write', 'contacts.read', 'messages.send', 'knowledge.write'];
|
|
8
|
+
export class ManifestError extends Error {
|
|
9
|
+
}
|
|
10
|
+
/** Accepts the published developer format and rewrites it to the canonical one. */
|
|
11
|
+
export function normalize(raw) {
|
|
12
|
+
const v = structuredClone(raw);
|
|
13
|
+
const auth = v?.auth;
|
|
14
|
+
if (auth && typeof auth === 'object') {
|
|
15
|
+
if (!('kind' in auth) && 'type' in auth) {
|
|
16
|
+
auth.kind = auth.type;
|
|
17
|
+
delete auth.type;
|
|
18
|
+
}
|
|
19
|
+
// format "Bearer {{secret}}": the text before the placeholder is the prefix.
|
|
20
|
+
if (typeof auth.format === 'string') {
|
|
21
|
+
const at = auth.format.indexOf('{{secret}}');
|
|
22
|
+
if (at >= 0 && !('prefix' in auth))
|
|
23
|
+
auth.prefix = auth.format.slice(0, at);
|
|
24
|
+
delete auth.format;
|
|
25
|
+
}
|
|
26
|
+
const kind = auth.kind ?? 'none';
|
|
27
|
+
const def = (k, val) => { if (!(k in auth))
|
|
28
|
+
auth[k] = val; };
|
|
29
|
+
if (kind === 'api_key') {
|
|
30
|
+
def('secret', 'api_key');
|
|
31
|
+
if (typeof auth.header === 'string') {
|
|
32
|
+
if (!('name' in auth))
|
|
33
|
+
auth.name = auth.header;
|
|
34
|
+
if (!('in' in auth))
|
|
35
|
+
auth.in = 'header';
|
|
36
|
+
}
|
|
37
|
+
else if (typeof auth.query === 'string') {
|
|
38
|
+
if (!('name' in auth))
|
|
39
|
+
auth.name = auth.query;
|
|
40
|
+
if (!('in' in auth))
|
|
41
|
+
auth.in = 'query';
|
|
42
|
+
}
|
|
43
|
+
delete auth.header;
|
|
44
|
+
delete auth.query;
|
|
45
|
+
}
|
|
46
|
+
else if (kind === 'basic') {
|
|
47
|
+
def('username', 'username');
|
|
48
|
+
def('password', 'password');
|
|
49
|
+
}
|
|
50
|
+
else if (kind === 'oauth')
|
|
51
|
+
def('secret', 'access_token');
|
|
52
|
+
else if (kind === 'bearer')
|
|
53
|
+
def('secret', 'token');
|
|
54
|
+
if (kind === 'oauth')
|
|
55
|
+
for (const k of Object.keys(auth))
|
|
56
|
+
if (!['kind', 'secret', 'scopes', 'prefix', 'token_url'].includes(k))
|
|
57
|
+
delete auth[k];
|
|
58
|
+
}
|
|
59
|
+
const scopes = v?.permissions?.scopes;
|
|
60
|
+
if (Array.isArray(scopes))
|
|
61
|
+
v.permissions.scopes = scopes.map((s) => typeof s === 'string' ? s.replaceAll(':', '.') : s);
|
|
62
|
+
if (Array.isArray(v?.actions)) {
|
|
63
|
+
for (const a of v.actions) {
|
|
64
|
+
if (a && typeof a === 'object' && !('kind' in a))
|
|
65
|
+
a.kind = 'http' in a ? 'http' : 'js';
|
|
66
|
+
if (a?.http && typeof a.http === 'object' && 'map' in a.http && !('output' in a.http)) {
|
|
67
|
+
a.http.output = a.http.map;
|
|
68
|
+
delete a.http.map;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
export function validId(id) {
|
|
75
|
+
return /^[a-z0-9._-]+$/.test(id) && id.length >= 2 && id.length <= 100 && /^[a-z0-9]/.test(id) && /[a-z0-9]$/.test(id) && !id.includes('..');
|
|
76
|
+
}
|
|
77
|
+
const validActionName = (n) => n.length > 0 && n.length <= 64 && n.split('.').every((p) => /^[a-z][A-Za-z0-9_]*$/.test(p));
|
|
78
|
+
export const configHostField = (e) => e.startsWith('{{config.') && e.endsWith('}}') ? e.slice(9, -2) : null;
|
|
79
|
+
export function validHostPattern(host) {
|
|
80
|
+
const bare = host.startsWith('*.') ? host.slice(2) : host;
|
|
81
|
+
return bare.length > 0 && bare.includes('.') && bare.length <= 253 && bare.split('.').every((l) => /^[a-z0-9-]+$/.test(l));
|
|
82
|
+
}
|
|
83
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
84
|
+
function compileSchema(what, schema) {
|
|
85
|
+
try {
|
|
86
|
+
ajv.compile(schema);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
throw new ManifestError(`${what} is not a valid JSON Schema: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Checks a value against a schema, like the server's check_schema(). */
|
|
93
|
+
export function checkSchema(what, schema, value) {
|
|
94
|
+
let validate;
|
|
95
|
+
try {
|
|
96
|
+
validate = ajv.compile(schema);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
throw new ManifestError(`${what} schema is invalid: ${e.message}`);
|
|
100
|
+
}
|
|
101
|
+
if (validate(value))
|
|
102
|
+
return;
|
|
103
|
+
const problems = (validate.errors ?? []).slice(0, 5).map((e) => (e.instancePath ? `${e.instancePath}: ` : '') + e.message);
|
|
104
|
+
throw new ManifestError(`${what} does not match its schema: ${problems.join('; ')}`);
|
|
105
|
+
}
|
|
106
|
+
const refs = (s) => [...s.matchAll(/\{\{(.*?)\}\}/g)].map((m) => m[1].trim());
|
|
107
|
+
/** Parses manifest.json text into the canonical manifest, or throws ManifestError. */
|
|
108
|
+
export function parseManifest(text) {
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = JSON.parse(text);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
throw new ManifestError(`manifest.json: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
const m = normalize(raw);
|
|
117
|
+
const fail = (msg) => { throw new ManifestError(msg); };
|
|
118
|
+
if (!m || typeof m !== 'object')
|
|
119
|
+
fail('manifest.json: expected an object');
|
|
120
|
+
for (const k of ['id', 'version', 'name'])
|
|
121
|
+
if (typeof m[k] !== 'string')
|
|
122
|
+
fail(`manifest.json: missing field \`${k}\``);
|
|
123
|
+
if (!Array.isArray(m.actions))
|
|
124
|
+
fail('manifest.json: missing field `actions`');
|
|
125
|
+
m.description ??= '';
|
|
126
|
+
m.category ??= 'other';
|
|
127
|
+
m.sensitive ??= false;
|
|
128
|
+
m.regions ??= [];
|
|
129
|
+
m.auth ??= { kind: 'none' };
|
|
130
|
+
m.config ??= { type: 'object' };
|
|
131
|
+
m.permissions = { hosts: m.permissions?.hosts ?? [], scopes: m.permissions?.scopes ?? [] };
|
|
132
|
+
m.knowledge ??= [];
|
|
133
|
+
m.triggers ??= [];
|
|
134
|
+
m.ui ??= [];
|
|
135
|
+
m.secrets ??= [];
|
|
136
|
+
if (!['none', 'api_key', 'bearer', 'basic', 'oauth'].includes(m.auth.kind))
|
|
137
|
+
fail(`manifest.json: unknown auth kind ${JSON.stringify(m.auth.kind)}`);
|
|
138
|
+
if (m.auth.kind === 'api_key' && typeof m.auth.name !== 'string')
|
|
139
|
+
fail('manifest.json: api_key auth needs `header` or `query`');
|
|
140
|
+
if (!Array.isArray(m.secrets))
|
|
141
|
+
fail('manifest.json: `secrets` is a list');
|
|
142
|
+
for (const sec of m.secrets) {
|
|
143
|
+
if (typeof sec?.name !== 'string' || !/^[A-Za-z0-9_]+$/.test(sec.name))
|
|
144
|
+
fail(`secret name ${JSON.stringify(sec?.name)}: letters, digits and _`);
|
|
145
|
+
sec.title ??= '';
|
|
146
|
+
sec.description ??= '';
|
|
147
|
+
}
|
|
148
|
+
if (!validId(m.id))
|
|
149
|
+
fail(`id ${JSON.stringify(m.id)} must be lowercase reverse-DNS, like "np.daraz.seller"`);
|
|
150
|
+
if (!SEMVER.test(m.version))
|
|
151
|
+
fail(`version ${JSON.stringify(m.version)} is not semver`);
|
|
152
|
+
if (!m.name.trim() || [...m.name].length > 80)
|
|
153
|
+
fail('name must be 1 to 80 characters');
|
|
154
|
+
if ([...m.description].length > 2000)
|
|
155
|
+
fail('description is over 2000 characters');
|
|
156
|
+
for (const host of m.permissions.hosts) {
|
|
157
|
+
const field = configHostField(host);
|
|
158
|
+
if (field !== null) {
|
|
159
|
+
if (!field || !/^[A-Za-z0-9_]+$/.test(field))
|
|
160
|
+
fail(`host ${JSON.stringify(host)}: use {{config.<field>}}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!validHostPattern(host))
|
|
164
|
+
fail(`host ${JSON.stringify(host)} must be a bare lowercase host name like "api.example.com" or "*.example.com"`);
|
|
165
|
+
}
|
|
166
|
+
for (const s of m.permissions.scopes)
|
|
167
|
+
if (!SCOPES.includes(s))
|
|
168
|
+
fail(`unknown scope ${JSON.stringify(s)}; known: ${SCOPES.join(', ')}`);
|
|
169
|
+
if (m.sensitive && m.regions.length === 0 && m.permissions.hosts.length > 0)
|
|
170
|
+
fail('a sensitive connector must declare the regions of its hosts');
|
|
171
|
+
compileSchema('config', m.config);
|
|
172
|
+
if (m.actions.length === 0)
|
|
173
|
+
fail('a connector needs at least one action');
|
|
174
|
+
if (m.actions.length > 100)
|
|
175
|
+
fail('at most 100 actions');
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
for (const a of m.actions) {
|
|
178
|
+
const n = a.name;
|
|
179
|
+
const q = JSON.stringify(n);
|
|
180
|
+
if (typeof n !== 'string' || !validActionName(n))
|
|
181
|
+
fail(`action name ${q} must be lowercase words joined by dots, like "order.create"`);
|
|
182
|
+
if (seen.has(n))
|
|
183
|
+
fail(`action ${q} is declared twice`);
|
|
184
|
+
seen.add(n);
|
|
185
|
+
if (typeof a.description !== 'string' || !a.description.trim())
|
|
186
|
+
fail(`action ${q} needs a description`);
|
|
187
|
+
if (!['read', 'change', 'money'].includes(a.risk))
|
|
188
|
+
fail(`action ${q}: risk is read, change or money`);
|
|
189
|
+
a.confirm ??= 'none';
|
|
190
|
+
if (!['none', 'caller', 'staff'].includes(a.confirm))
|
|
191
|
+
fail(`action ${q}: confirm is none, caller or staff`);
|
|
192
|
+
if (a.input === undefined)
|
|
193
|
+
fail(`action ${q}: missing field \`input\``);
|
|
194
|
+
if (a.output === undefined)
|
|
195
|
+
fail(`action ${q}: missing field \`output\``);
|
|
196
|
+
compileSchema(`action ${q} input`, a.input);
|
|
197
|
+
compileSchema(`action ${q} output`, a.output);
|
|
198
|
+
if (a.cache_seconds !== undefined && (!Number.isInteger(a.cache_seconds) || a.cache_seconds < 0))
|
|
199
|
+
fail(`action ${q}: cache_seconds is a whole number of seconds`);
|
|
200
|
+
if ((a.cache_seconds ?? 0) > 0 && a.risk !== 'read')
|
|
201
|
+
fail(`action ${q} changes something, so it cannot be cached`);
|
|
202
|
+
if ((a.cache_seconds ?? 0) > 300)
|
|
203
|
+
fail(`action ${q}: cache_seconds is at most 300`);
|
|
204
|
+
if (a.risk === 'money' && a.confirm === 'none')
|
|
205
|
+
fail(`action ${q} moves money, so it must need confirmation (caller or staff)`);
|
|
206
|
+
if (a.kind === 'http' && !a.http)
|
|
207
|
+
fail(`http action ${q} needs an \`http\` block`);
|
|
208
|
+
if (a.kind === 'js' && a.http)
|
|
209
|
+
fail(`js action ${q} must not have an \`http\` block`);
|
|
210
|
+
if (a.kind === 'http')
|
|
211
|
+
validateHttp(m, n, a.http);
|
|
212
|
+
a.fixtures ??= [];
|
|
213
|
+
}
|
|
214
|
+
for (const k of m.knowledge)
|
|
215
|
+
if (!m.actions.some((a) => a.name === k.action))
|
|
216
|
+
fail(`knowledge ${JSON.stringify(k.name)} names unknown action ${JSON.stringify(k.action)}`);
|
|
217
|
+
for (const t of m.triggers)
|
|
218
|
+
if (!['webhook', 'schedule'].includes(t.source))
|
|
219
|
+
fail(`trigger ${JSON.stringify(t.name)}: source is webhook or schedule`);
|
|
220
|
+
for (const p of m.ui) {
|
|
221
|
+
if (!['main', 'contact', 'inbox'].includes(p.placement) || !String(p.entry).startsWith('ui/') || String(p.entry).includes('..'))
|
|
222
|
+
fail(`ui panel ${JSON.stringify(p.name)}: placement main|contact|inbox and an entry under ui/`);
|
|
223
|
+
const bad = (p.bridge_apis ?? []).find((a) => !['auth.session', 'ui.toast', 'ui.resize', 'nav.open', 'contact.current'].includes(a));
|
|
224
|
+
if (bad)
|
|
225
|
+
fail(`ui panel ${JSON.stringify(p.name)}: unknown bridge call ${JSON.stringify(bad)}`);
|
|
226
|
+
}
|
|
227
|
+
return m;
|
|
228
|
+
}
|
|
229
|
+
function validateHttp(m, action, spec) {
|
|
230
|
+
const q = JSON.stringify(action);
|
|
231
|
+
const fail = (msg) => { throw new ManifestError(msg); };
|
|
232
|
+
if (typeof spec.method !== 'string' || typeof spec.url !== 'string')
|
|
233
|
+
fail(`action ${q}: http needs method and url`);
|
|
234
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(spec.method.toUpperCase()))
|
|
235
|
+
fail(`action ${q}: method ${spec.method} not allowed`);
|
|
236
|
+
const refsSecret = (s) => refs(s).some((r) => r.startsWith('secrets.'));
|
|
237
|
+
if (refsSecret(spec.url))
|
|
238
|
+
fail(`action ${q}: secrets may go in headers or query only, not the URL`);
|
|
239
|
+
if (spec.body !== undefined && refsSecret(JSON.stringify(spec.body)))
|
|
240
|
+
fail(`action ${q}: secrets may go in headers or query only, not the body`);
|
|
241
|
+
if (spec.fail_when !== undefined && !Array.isArray(spec.fail_when))
|
|
242
|
+
fail(`action ${q}: fail_when is a list`);
|
|
243
|
+
for (const rule of spec.fail_when ?? []) {
|
|
244
|
+
for (const path of [rule?.path, ...(rule?.message_path === undefined ? [] : [rule.message_path])]) {
|
|
245
|
+
if (typeof path !== 'string' || !path.startsWith('$'))
|
|
246
|
+
fail(`action ${q}: fail_when path ${JSON.stringify(path)}: a JSONPath starting with $`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (spec.url.startsWith('{{config.'))
|
|
250
|
+
return;
|
|
251
|
+
if (!spec.url.startsWith('https://'))
|
|
252
|
+
fail(`action ${q}: the URL must start with https:// or {{config.<field>}}`);
|
|
253
|
+
const host = spec.url.slice(8).split(/[/?:]/)[0];
|
|
254
|
+
if (!host || host.includes('{{'))
|
|
255
|
+
fail(`action ${q}: the URL host must be literal`);
|
|
256
|
+
const literal = m.permissions.hosts.filter((h) => !h.startsWith('{{'));
|
|
257
|
+
if (!hostAllowed(literal, host))
|
|
258
|
+
fail(`action ${q} calls ${host}, which is not in permissions.hosts`);
|
|
259
|
+
}
|
|
260
|
+
/** net.rs host_allowed(). */
|
|
261
|
+
export function hostAllowed(patterns, host) {
|
|
262
|
+
return patterns.some((p) => p.startsWith('*.') ? host.length > p.length - 1 && host.endsWith(p.slice(1)) : host === p);
|
|
263
|
+
}
|
|
264
|
+
/** Every secret an install holds: the ones auth names, then `secrets` (manifest.rs secret_names). */
|
|
265
|
+
export function secretNames(m) {
|
|
266
|
+
const a = m.auth;
|
|
267
|
+
const names = [];
|
|
268
|
+
if (a.kind === 'basic')
|
|
269
|
+
names.push(String(a.username), String(a.password));
|
|
270
|
+
else if (typeof a.secret === 'string' && a.kind !== 'none')
|
|
271
|
+
names.push(a.secret);
|
|
272
|
+
for (const s of m.secrets ?? [])
|
|
273
|
+
if (!names.includes(s.name))
|
|
274
|
+
names.push(s.name);
|
|
275
|
+
return names;
|
|
276
|
+
}
|
|
277
|
+
/** The config with each schema property's `default` filled in where missing (manifest.rs with_config_defaults). */
|
|
278
|
+
export function withConfigDefaults(schema, config) {
|
|
279
|
+
const out = config && typeof config === 'object' && !Array.isArray(config) ? { ...config } : {};
|
|
280
|
+
const props = schema?.properties;
|
|
281
|
+
if (props && typeof props === 'object')
|
|
282
|
+
for (const [k, p] of Object.entries(props))
|
|
283
|
+
if (p && typeof p === 'object' && 'default' in p && !(k in out))
|
|
284
|
+
out[k] = p.default;
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/** Hosts this install may call: literal patterns plus the hosts of `{{config.x}}` URLs. */
|
|
288
|
+
export function allowedHosts(m, config) {
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const entry of m.permissions.hosts) {
|
|
291
|
+
const field = configHostField(entry);
|
|
292
|
+
if (field === null) {
|
|
293
|
+
out.push(entry);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const u = new URL(String(config?.[field]));
|
|
298
|
+
out.push(u.hostname.toLowerCase());
|
|
299
|
+
}
|
|
300
|
+
catch { /* not set */ }
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/** Added hosts and scopes since a previous version (manifest.rs permission_diff). */
|
|
305
|
+
export function permissionDiff(next, prev) {
|
|
306
|
+
const added = [];
|
|
307
|
+
for (const h of next.permissions.hosts)
|
|
308
|
+
if (!prev.permissions.hosts.includes(h))
|
|
309
|
+
added.push(`host ${h}`);
|
|
310
|
+
for (const s of next.permissions.scopes)
|
|
311
|
+
if (!prev.permissions.scopes.includes(s))
|
|
312
|
+
added.push(`scope ${s}`);
|
|
313
|
+
if (next.sensitive && !prev.sensitive)
|
|
314
|
+
added.push('sensitive');
|
|
315
|
+
return added;
|
|
316
|
+
}
|
package/dist/net.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const MAX_RESPONSE_BYTES: number;
|
|
2
|
+
export declare const HTTP_TIMEOUT_MS = 10000;
|
|
3
|
+
export interface OutRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
headers: [string, string][];
|
|
7
|
+
query: [string, string][];
|
|
8
|
+
body: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface OutResponse {
|
|
11
|
+
status: number;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
body: string;
|
|
14
|
+
}
|
|
15
|
+
export interface EgressOptions {
|
|
16
|
+
/** `--allow-local`: lets http:// and private addresses through, for a mock server on your machine. Never true on AirFone. */
|
|
17
|
+
allowLocal?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
20
|
+
export declare function send(allowed: string[], req: OutRequest, opts?: EgressOptions): Promise<OutResponse>;
|
package/dist/net.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Outbound HTTP for local runs, with the server's rules (net.rs): only
|
|
2
|
+
// declared hosts, https only, never private or local addresses after DNS,
|
|
3
|
+
// no redirects, at most 4 MB of response, 10 s per request.
|
|
4
|
+
import { lookup } from 'node:dns/promises';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { hostAllowed } from './manifest.js';
|
|
7
|
+
export const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
export const HTTP_TIMEOUT_MS = 10_000;
|
|
9
|
+
function blockedV4(ip) {
|
|
10
|
+
const o = ip.split('.').map(Number);
|
|
11
|
+
return o[0] === 127 || o[0] === 10 || (o[0] === 172 && o[1] >= 16 && o[1] <= 31) || (o[0] === 192 && o[1] === 168)
|
|
12
|
+
|| (o[0] === 169 && o[1] === 254) || o[0] === 0 || o[0] >= 224
|
|
13
|
+
|| (o[0] === 100 && (o[1] & 0xc0) === 64) || (o[0] === 192 && o[1] === 0 && o[2] === 0) || (o[0] === 198 && (o[1] & 0xfe) === 18)
|
|
14
|
+
|| (o[0] === 192 && o[1] === 0 && o[2] === 2) || (o[0] === 198 && o[1] === 51 && o[2] === 100) || (o[0] === 203 && o[1] === 0 && o[2] === 113);
|
|
15
|
+
}
|
|
16
|
+
export function isBlockedIp(ip) {
|
|
17
|
+
if (isIP(ip) === 4)
|
|
18
|
+
return blockedV4(ip);
|
|
19
|
+
const v6 = ip.toLowerCase();
|
|
20
|
+
const mapped = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
21
|
+
if (mapped)
|
|
22
|
+
return blockedV4(mapped[1]);
|
|
23
|
+
return v6 === '::1' || v6 === '::' || v6.startsWith('fe8') || v6.startsWith('fe9') || v6.startsWith('fea') || v6.startsWith('feb')
|
|
24
|
+
|| v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('ff') || v6.startsWith('64:ff9b:') || v6.startsWith('2001:db8');
|
|
25
|
+
}
|
|
26
|
+
export async function send(allowed, req, opts = {}) {
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(req.url);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`bad URL ${req.url}`);
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol !== 'https:' && !(opts.allowLocal && url.protocol === 'http:'))
|
|
35
|
+
throw new Error('only https URLs are allowed');
|
|
36
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
37
|
+
const testIp = opts.allowLocal && isIP(host) !== 0;
|
|
38
|
+
if (!testIp && !hostAllowed(allowed, host))
|
|
39
|
+
throw new Error(`host ${host} is not declared in permissions.hosts`);
|
|
40
|
+
const addrs = isIP(host) ? [{ address: host }] : await lookup(host, { all: true }).catch((e) => { throw new Error(`cannot resolve ${host}: ${e.code ?? e.message}`); });
|
|
41
|
+
if (!addrs.length)
|
|
42
|
+
throw new Error(`${host} has no address`);
|
|
43
|
+
if (!opts.allowLocal) {
|
|
44
|
+
const bad = addrs.find((a) => isBlockedIp(a.address));
|
|
45
|
+
if (bad)
|
|
46
|
+
throw new Error(`${host} resolves to a private address (${bad.address})`);
|
|
47
|
+
}
|
|
48
|
+
for (const [k, v] of req.query)
|
|
49
|
+
url.searchParams.append(k, v);
|
|
50
|
+
const headers = new Headers();
|
|
51
|
+
for (const [k, v] of req.headers)
|
|
52
|
+
if (k.toLowerCase() !== 'host')
|
|
53
|
+
headers.append(k, v);
|
|
54
|
+
const ctrl = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
const r = await fetch(url, { method: req.method, headers, body: req.body ?? undefined, redirect: 'manual', signal: ctrl.signal });
|
|
58
|
+
const buf = new Uint8Array(await r.arrayBuffer());
|
|
59
|
+
if (buf.length > MAX_RESPONSE_BYTES)
|
|
60
|
+
throw new Error(`${host} answered with more than ${MAX_RESPONSE_BYTES} bytes`);
|
|
61
|
+
const out = {};
|
|
62
|
+
r.headers.forEach((v, k) => { out[k] = v; });
|
|
63
|
+
return { status: r.status, headers: out, body: new TextDecoder().decode(buf) };
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e.name === 'AbortError')
|
|
67
|
+
throw new Error(`${host} timed out`);
|
|
68
|
+
throw new Error(`${host}: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/review.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Canonical } from './manifest.js';
|
|
2
|
+
export interface Finding {
|
|
3
|
+
level: 'error' | 'warning';
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Fixture {
|
|
7
|
+
action: string;
|
|
8
|
+
label: string;
|
|
9
|
+
input: unknown;
|
|
10
|
+
config?: unknown;
|
|
11
|
+
expect?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare function subset(expected: unknown, actual: unknown): boolean;
|
|
14
|
+
/** Fixtures from the manifest, plus tests/*.json files ({ action, input, config?, expect? } or an array of them). These run locally only. */
|
|
15
|
+
export declare function fixtures(m: Canonical, dir: string): Fixture[];
|
|
16
|
+
export declare function review(m: Canonical, js: string | null, list: Fixture[]): Promise<Finding[]>;
|