@friggframework/core 2.0.0--canary.696.5d9f1be.0 → 2.0.0--canary.698.b1a2d30.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/integrations/data-defined/README.md +31 -1
- package/integrations/data-defined/capabilities/AUDIT.md +451 -0
- package/integrations/data-defined/capabilities/asana.capabilities.json +22 -13
- package/integrations/data-defined/capabilities/fathom.capabilities.json +16 -6
- package/integrations/data-defined/capabilities/hubspot.capabilities.json +78 -27
- package/integrations/data-defined/capabilities/linear.capabilities.json +30 -10
- package/integrations/data-defined/capabilities/phone.capabilities.json +24 -7
- package/integrations/data-defined/capabilities/pipedrive.capabilities.json +26 -11
- package/integrations/data-defined/capabilities/salesforce.capabilities.json +64 -18
- package/integrations/data-defined/capabilities/slack.capabilities.json +12 -5
- package/integrations/data-defined/capabilities/stripe.capabilities.json +28 -10
- package/integrations/data-defined/data-defined-integration.js +2 -2
- package/integrations/data-defined/examples/asana-slack.definition.json +4 -5
- package/integrations/data-defined/examples/hubspot-slack.definition.json +4 -4
- package/integrations/data-defined/examples/klaviyo-hubspot.definition.json +1 -4
- package/integrations/data-defined/examples/phone-hubspot.definition.json +1 -5
- package/integrations/data-defined/expressions.js +2 -0
- package/integrations/data-defined/index.js +6 -0
- package/integrations/data-defined/manifests.js +18 -0
- package/integrations/data-defined/renderers/common.js +67 -0
- package/integrations/data-defined/renderers/html.js +129 -0
- package/integrations/data-defined/renderers/hubspot.js +54 -0
- package/integrations/data-defined/renderers/preview.js +154 -0
- package/integrations/data-defined/renderers/slack.js +146 -0
- package/integrations/data-defined/runtime.js +17 -2
- package/integrations/data-defined/schema/capability-manifest.v0.1.schema.json +40 -0
- package/integrations/data-defined/scripts/check-manifests.js +220 -0
- package/integrations/data-defined/tests/helpers-fake-crm.js +11 -6
- package/integrations/data-defined/validate.js +6 -0
- package/package.json +5 -5
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe, self-contained HTML for a preview pane. Every piece of data goes through `esc`, links are http(s) only, styles
|
|
3
|
+
* are inline attributes and there is no script, no `<style>`, no event handler and no external resource, so a pane can
|
|
4
|
+
* show a vendor's record next to the definition's chrome without that record being able to do anything.
|
|
5
|
+
*/
|
|
6
|
+
const { str, safeUrl, toneOf } = require('./common');
|
|
7
|
+
|
|
8
|
+
const esc = (v) => str(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
9
|
+
const FONT = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif";
|
|
10
|
+
|
|
11
|
+
/** How each host frames what it shows. `generic` is for hosts with no look of their own (a phone app, Zendesk, Shopify…). */
|
|
12
|
+
const HOSTS = {
|
|
13
|
+
hubspot: { label: 'HubSpot card', frame: `${FONT};background:#fff;border:1px solid #cbd6e2;border-radius:3px;max-width:420px;color:#33475b;font-size:14px;box-shadow:0 1px 2px rgba(0,0,0,.08)`, head: 'background:#f5f8fa;border-bottom:1px solid #cbd6e2;padding:8px 14px;font-size:12px;color:#516f90;font-weight:600', body: 'padding:14px', accent: '#ff7a59' },
|
|
14
|
+
slack: { label: 'Slack message', frame: `${FONT};background:#fff;border:1px solid #e0e0e0;border-radius:8px;max-width:560px;color:#1d1c1d;font-size:15px`, head: 'padding:8px 14px 0;font-size:13px;color:#616061', body: 'padding:6px 14px 12px', accent: '#4a154b' },
|
|
15
|
+
generic: { label: 'Preview', frame: `${FONT};background:#fff;border:1px solid #d9dde3;border-radius:6px;max-width:480px;color:#1f2937;font-size:14px`, head: 'background:#f3f4f6;border-bottom:1px solid #d9dde3;padding:8px 14px;font-size:12px;color:#6b7280;font-weight:600', body: 'padding:14px', accent: '#2563eb' },
|
|
16
|
+
};
|
|
17
|
+
const hostOf = (h) => HOSTS[h] || HOSTS.generic;
|
|
18
|
+
const TONE = { positive: 'background:#e6f4ea;color:#1e6b34', warning: 'background:#fff4d6;color:#8a5a00', critical: 'background:#fde8e6;color:#a4261b', neutral: 'background:#eceff3;color:#44505f' };
|
|
19
|
+
|
|
20
|
+
/** The view tree as HTML, without the host frame. */
|
|
21
|
+
function viewToHtmlBody(view, host = 'generic') {
|
|
22
|
+
const H = hostOf(host);
|
|
23
|
+
const go = (v) => {
|
|
24
|
+
if (!v || typeof v !== 'object') return '';
|
|
25
|
+
const p = v.props || {};
|
|
26
|
+
const kids = () => (v.children || []).map(go).join('');
|
|
27
|
+
switch (v.component) {
|
|
28
|
+
case 'Stack': return `<div style="display:flex;flex-direction:column;gap:10px">${kids()}</div>`;
|
|
29
|
+
case 'Heading': return `<h3 style="margin:0;font-size:16px;font-weight:700">${esc(p.text)}</h3>`;
|
|
30
|
+
case 'Text': return `<p style="margin:0;line-height:1.4">${esc(p.text)}</p>`;
|
|
31
|
+
case 'KeyValue': return `<dl style="margin:0;display:grid;grid-template-columns:max-content 1fr;gap:4px 14px">${(Array.isArray(p.items) ? p.items : []).map((it) => `<dt style="color:#6b7280">${esc(it?.label)}</dt><dd style="margin:0">${esc(it?.value) || '—'}</dd>`).join('')}</dl>`;
|
|
32
|
+
case 'List': return `<ul style="margin:0;padding-left:18px">${(Array.isArray(p.items) ? p.items : []).map((it) => `<li>${esc(it?.title)}${it?.meta ? ` <span style="color:#6b7280;font-size:12px">${esc(it.meta)}</span>` : ''}</li>`).join('')}</ul>`;
|
|
33
|
+
case 'Table': return `<table style="border-collapse:collapse;width:100%"><thead><tr>${(Array.isArray(p.columns) ? p.columns : []).map((c) => `<th style="text-align:left;border-bottom:1px solid #d9dde3;padding:4px 8px;font-size:12px;color:#6b7280">${esc(c)}</th>`).join('')}</tr></thead><tbody>${(Array.isArray(p.rows) ? p.rows : []).map((r) => `<tr>${(Array.isArray(r) ? r : []).map((c) => `<td style="border-bottom:1px solid #eef0f3;padding:4px 8px">${esc(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
34
|
+
case 'Badge': return `<span style="display:inline-block;align-self:flex-start;padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600;${TONE[toneOf(p.tone)]}">${esc(p.text)}</span>`;
|
|
35
|
+
case 'Empty': return `<p style="margin:0;color:#6b7280;font-style:italic">${esc(p.text)}</p>`;
|
|
36
|
+
case 'Divider': return '<hr style="border:0;border-top:1px solid #e5e7eb;margin:2px 0">';
|
|
37
|
+
case 'Button': return buttonHtml({ label: p.label, action: p.action, primary: p.style === 'primary', accent: H.accent });
|
|
38
|
+
default: return kids();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return go(view);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A button in a preview does nothing (no script), but says what it would do: a link for open_url, a `data-` marker for a workflow. */
|
|
45
|
+
function buttonHtml({ label, action, primary, accent }) {
|
|
46
|
+
const style = `display:inline-block;align-self:flex-start;padding:6px 14px;border-radius:4px;font-size:14px;font-weight:600;text-decoration:none;cursor:default;${primary ? `background:${accent};color:#fff;border:1px solid ${accent}` : `background:#fff;color:${accent};border:1px solid ${accent}`}`;
|
|
47
|
+
if (action?.type === 'open_url') {
|
|
48
|
+
const url = safeUrl(action.url);
|
|
49
|
+
if (url) return `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="${style}">${esc(label || 'Open')}</a>`;
|
|
50
|
+
}
|
|
51
|
+
const wf = action?.type === 'userAction' ? ` data-workflow="${esc(action.workflow)}" title="${esc(`Runs the "${action.workflow}" workflow`)}"` : '';
|
|
52
|
+
return `<button type="button"${wf} style="${style}">${esc(label || 'Open')}</button>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The framed preview: a head line saying what this is and where it appears, then the body. */
|
|
56
|
+
function frame(host, title, bodyHtml) {
|
|
57
|
+
const H = hostOf(host);
|
|
58
|
+
return `<div data-preview-host="${esc(host in HOSTS ? host : 'generic')}" style="${H.frame}"><div style="${H.head}">${esc(title || H.label)}</div><div style="${H.body}">${bodyHtml}</div></div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {object|null} view
|
|
63
|
+
* @param {{ host?: string, title?: string }} [opts]
|
|
64
|
+
*/
|
|
65
|
+
function viewToHtml(view, { host = 'generic', title } = {}) {
|
|
66
|
+
const body = view ? viewToHtmlBody(view, host) : `<p style="margin:0;color:#6b7280;font-style:italic">Nothing to show for this record.</p>`;
|
|
67
|
+
return frame(host, title, body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Slack: mrkdwn and Block Kit as HTML, so a posted message previews as it will look, not as a data dump ----
|
|
71
|
+
|
|
72
|
+
/** Slack mrkdwn (already Slack-encoded: `&`, `<url|label>`) to HTML. Only `* _ ~ \`` and links are interpreted. */
|
|
73
|
+
function mrkdwnToHtml(text) {
|
|
74
|
+
const parts = str(text).split(/(<[^<>\n]*>)/);
|
|
75
|
+
const out = parts.map((seg) => {
|
|
76
|
+
const tok = /^<([^<>\n]*)>$/.exec(seg);
|
|
77
|
+
if (tok) {
|
|
78
|
+
// the target is Slack-encoded like the rest (`&` in a query string): decode it once, then esc() it as an attribute
|
|
79
|
+
const [rawTarget, label] = tok[1].split('|');
|
|
80
|
+
const target = rawTarget.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
81
|
+
if (/^https?:\/\//i.test(target)) { const url = safeUrl(target); return url ? `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="color:#1264a3">${esc(label || target)}</a>` : esc(label || ''); }
|
|
82
|
+
if (target.startsWith('!')) return `<span style="background:#fff3cd">@${esc(label || target.slice(1).split('^')[0])}</span>`;
|
|
83
|
+
if (target.startsWith('@')) return `<span style="background:#e8f5fa;color:#1264a3">@${esc(label || target.slice(1))}</span>`;
|
|
84
|
+
if (target.startsWith('#')) return `<span style="color:#1264a3">#${esc(label || target.slice(1))}</span>`;
|
|
85
|
+
return esc(label || '');
|
|
86
|
+
}
|
|
87
|
+
// decode Slack's three entities once, then escape for HTML, then emphasis on the escaped text
|
|
88
|
+
const raw = seg.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
89
|
+
return esc(raw)
|
|
90
|
+
.replace(/`([^`\n]+)`/g, '<code style="background:#f4f4f4;border:1px solid #e0e0e0;border-radius:3px;padding:0 3px">$1</code>')
|
|
91
|
+
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, '$1<strong>$2</strong>')
|
|
92
|
+
.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, '$1<em>$2</em>')
|
|
93
|
+
.replace(/(^|[\s(])~([^~\n]+)~(?=$|[\s).,!?:;])/g, '$1<del>$2</del>');
|
|
94
|
+
}).join('');
|
|
95
|
+
return out.replace(/\n/g, '<br>');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const textOf = (t) => (t && t.type === 'mrkdwn' ? mrkdwnToHtml(t.text) : esc(t?.text));
|
|
99
|
+
|
|
100
|
+
function slackBlocksToHtml(blocks) {
|
|
101
|
+
return (Array.isArray(blocks) ? blocks : []).map((b) => {
|
|
102
|
+
switch (b?.type) {
|
|
103
|
+
case 'header': return `<div style="font-size:18px;font-weight:700;margin:6px 0">${esc(b.text?.text)}</div>`;
|
|
104
|
+
case 'section': return `<div style="margin:4px 0">${b.text ? `<div>${textOf(b.text)}</div>` : ''}${Array.isArray(b.fields) ? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;margin-top:4px">${b.fields.map((f) => `<div>${textOf(f)}</div>`).join('')}</div>` : ''}</div>`;
|
|
105
|
+
case 'context': return `<div style="color:#616061;font-size:12px;margin:4px 0">${(b.elements || []).map(textOf).join(' ')}</div>`;
|
|
106
|
+
case 'divider': return '<hr style="border:0;border-top:1px solid #e0e0e0;margin:8px 0">';
|
|
107
|
+
case 'actions': return `<div style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">${(b.elements || []).map((e) => (e.url
|
|
108
|
+
? buttonHtml({ label: e.text?.text, action: { type: 'open_url', url: e.url }, accent: '#1264a3' })
|
|
109
|
+
: buttonHtml({ label: e.text?.text, action: { type: 'userAction', workflow: safeWorkflow(e.value) }, primary: e.style === 'primary', accent: e.style === 'primary' ? '#007a5a' : '#1264a3' }))).join('')}</div>`;
|
|
110
|
+
default: return `<div style="color:#616061;font-size:12px">(${esc(b?.type || 'unknown')} block)</div>`;
|
|
111
|
+
}
|
|
112
|
+
}).join('');
|
|
113
|
+
}
|
|
114
|
+
function safeWorkflow(value) { try { const w = JSON.parse(value)?.workflow; return typeof w === 'string' ? w : ''; } catch (_) { return ''; } }
|
|
115
|
+
|
|
116
|
+
/** A Slack message as the channel will show it: who posts it, where, then the blocks (or the text). */
|
|
117
|
+
function slackMessageToHtml({ channel, text, blocks, appName = 'Your integration' }) {
|
|
118
|
+
const to = channel ? esc(channel) : '<em>(channel not chosen yet)</em>';
|
|
119
|
+
const body = Array.isArray(blocks) && blocks.length ? slackBlocksToHtml(blocks) : `<div>${mrkdwnToHtml(text)}</div>`;
|
|
120
|
+
return `<div data-preview-host="slack" style="${HOSTS.slack.frame}"><div style="${HOSTS.slack.head}"><strong style="color:#1d1c1d">${esc(appName)}</strong> <span style="background:#e8e8e8;border-radius:3px;padding:0 4px;font-size:11px">APP</span> to ${to}</div><div style="${HOSTS.slack.body}">${body}</div></div>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Any other outbound call: what it is and what it carries. The values are escaped, and objects are shown as JSON. */
|
|
124
|
+
function callToHtml({ module, capability, args }) {
|
|
125
|
+
const rows = Object.entries(args || {}).map(([k, v]) => `<dt style="color:#6b7280">${esc(k)}</dt><dd style="margin:0;white-space:pre-wrap;word-break:break-word">${esc(typeof v === 'string' ? v : JSON.stringify(v, null, 2))}</dd>`).join('');
|
|
126
|
+
return frame('generic', `Would call ${module} · ${capability}`, `<dl style="margin:0;display:grid;grid-template-columns:max-content 1fr;gap:4px 14px">${rows}</dl>`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { viewToHtml, viewToHtmlBody, slackBlocksToHtml, slackMessageToHtml, mrkdwnToHtml, callToHtml, esc, HOSTS };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved view tree -> a JSON description of HubSpot UI-extension components (`@hubspot/ui-extensions` names). Data,
|
|
3
|
+
* not React: a thin card deployed once maps `{ type, props, children }` onto `React.createElement(components[type], ...)`
|
|
4
|
+
* and routes `props.action` to the actions endpoint, so a changed card is a publish, not a card deploy.
|
|
5
|
+
*
|
|
6
|
+
* A node is `{ type, props?, children? }`; a child is a string (text) or a node. Strings are text, never markup.
|
|
7
|
+
*/
|
|
8
|
+
const { str, safeUrl, toneOf } = require('./common');
|
|
9
|
+
|
|
10
|
+
/** The component names this emits, so the card can fail closed on one it does not know. */
|
|
11
|
+
const HUBSPOT_COMPONENTS = ['Flex', 'Heading', 'Text', 'List', 'Tag', 'Button', 'EmptyState', 'DescriptionList', 'DescriptionListItem', 'Divider', 'Table', 'TableHead', 'TableBody', 'TableRow', 'TableHeader', 'TableCell'];
|
|
12
|
+
const TAG_VARIANT = { positive: 'success', warning: 'warning', critical: 'error', neutral: 'default' };
|
|
13
|
+
|
|
14
|
+
const node = (type, props, children) => ({ type, ...(props && Object.keys(props).length ? { props } : {}), ...(children && children.length ? { children } : {}) });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object|null} view
|
|
18
|
+
* @param {{ integrationId?: string, surface?: string }} [opts] carried on a button's `action`, so the card can post it back
|
|
19
|
+
* @returns {object|null}
|
|
20
|
+
*/
|
|
21
|
+
function viewToHubSpot(view, { integrationId, surface } = {}) {
|
|
22
|
+
const go = (v) => {
|
|
23
|
+
if (!v || typeof v !== 'object') return null;
|
|
24
|
+
const p = v.props || {};
|
|
25
|
+
const kids = () => (v.children || []).map(go).filter(Boolean);
|
|
26
|
+
switch (v.component) {
|
|
27
|
+
case 'Stack': return node('Flex', { direction: 'column', gap: 'sm' }, kids());
|
|
28
|
+
case 'Heading': return node('Heading', {}, [str(p.text)]);
|
|
29
|
+
case 'Text': return node('Text', {}, [str(p.text)]);
|
|
30
|
+
case 'KeyValue': return node('DescriptionList', { direction: 'row' }, (Array.isArray(p.items) ? p.items : []).map((it) => node('DescriptionListItem', { label: str(it?.label) }, [node('Text', {}, [str(it?.value) || '—'])])));
|
|
31
|
+
case 'List': return node('List', { variant: 'unordered' }, (Array.isArray(p.items) ? p.items : []).map((it) => node('Flex', { direction: 'column', gap: 'flush' }, [node('Text', { format: { fontWeight: 'demibold' } }, [str(it?.title)]), ...(it?.meta ? [node('Text', { variant: 'microcopy' }, [str(it.meta)])] : [])])));
|
|
32
|
+
case 'Table': return node('Table', {}, [
|
|
33
|
+
node('TableHead', {}, [node('TableRow', {}, (Array.isArray(p.columns) ? p.columns : []).map((c) => node('TableHeader', {}, [str(c)])))]),
|
|
34
|
+
node('TableBody', {}, (Array.isArray(p.rows) ? p.rows : []).map((r) => node('TableRow', {}, (Array.isArray(r) ? r : []).map((c) => node('TableCell', {}, [str(c)]))))),
|
|
35
|
+
]);
|
|
36
|
+
case 'Badge': return node('Tag', { variant: TAG_VARIANT[toneOf(p.tone)] }, [str(p.text)]);
|
|
37
|
+
case 'Empty': return node('EmptyState', { title: str(p.text), layout: 'vertical' });
|
|
38
|
+
case 'Divider': return node('Divider', { distance: 'sm' });
|
|
39
|
+
case 'Button': {
|
|
40
|
+
const a = p.action;
|
|
41
|
+
const variant = p.style === 'primary' ? 'primary' : p.style === 'danger' ? 'destructive' : 'secondary';
|
|
42
|
+
let action;
|
|
43
|
+
if (a?.type === 'userAction' && typeof a.workflow === 'string') action = { type: 'userAction', workflow: a.workflow, surface, ...(integrationId ? { integrationId: String(integrationId) } : {}), ...(a.input ? { input: a.input } : {}) };
|
|
44
|
+
else if (a?.type === 'open_url' && safeUrl(a.url)) action = { type: 'open_url', url: safeUrl(a.url) };
|
|
45
|
+
// A button with no usable action still renders (disabled), so the card matches the definition.
|
|
46
|
+
return node('Button', { variant, ...(action ? { action } : { disabled: true }) }, [str(p.label) || 'Open']);
|
|
47
|
+
}
|
|
48
|
+
default: return kids().length === 1 ? kids()[0] : node('Flex', { direction: 'column', gap: 'sm' }, kids());
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return go(view);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { viewToHubSpot, HUBSPOT_COMPONENTS };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Previews: what an integration will show inside the apps it connects, rendered from the current settings and sample
|
|
3
|
+
* or real records. Same code path as production for everything that decides the content (queries, view resolution,
|
|
4
|
+
* the interpreter and its steps); the only thing swapped is where a capability call goes.
|
|
5
|
+
*
|
|
6
|
+
* previewSurface a UI surface (a HubSpot card, a Slack message menu...) -> view + html (+ slack blocks / hubspot tree)
|
|
7
|
+
* previewWorkflow dry-run a workflow: every outbound call is RECORDED with the args it would send, none is made
|
|
8
|
+
*/
|
|
9
|
+
const { makePorts, renderSurface, runWorkflow, recordOf } = require('../runtime');
|
|
10
|
+
const { isBuiltin } = require('../steps');
|
|
11
|
+
const { viewToSlack } = require('./slack');
|
|
12
|
+
const { viewToHubSpot } = require('./hubspot');
|
|
13
|
+
const { viewToHtml, slackMessageToHtml, callToHtml, slackBlocksToHtml, esc, HOSTS } = require('./html');
|
|
14
|
+
|
|
15
|
+
class DryRunError extends Error { constructor(what) { super(`dry run: ${what} was not performed`); this.name = 'DryRunError'; } }
|
|
16
|
+
|
|
17
|
+
const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
|
|
18
|
+
|
|
19
|
+
/** A plausible empty record for a manifest object: ids are filled (a step that checks `$.steps.x.id exists` proceeds), the rest is empty of its type. */
|
|
20
|
+
function sampleObject(manifest, name) {
|
|
21
|
+
const o = manifest?.objects?.[name];
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const f of o?.fields || []) {
|
|
24
|
+
const keys = String(f.name).split('.');
|
|
25
|
+
let cur = out;
|
|
26
|
+
keys.slice(0, -1).forEach((k) => { cur = (cur[k] = cur[k] && typeof cur[k] === 'object' ? cur[k] : {}); });
|
|
27
|
+
const empty = f.type === 'id' ? `preview-${name}-${f.name}` : f.type === 'number' ? 0 : f.type === 'boolean' ? false : f.type === 'json' ? {} : f.type === 'array' ? [] : '';
|
|
28
|
+
cur[keys[keys.length - 1]] = empty;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function sampleOutput(manifest, cap) {
|
|
33
|
+
const o = String(cap?.output || '');
|
|
34
|
+
if (o.endsWith('[]')) return [];
|
|
35
|
+
if (/^page</.test(o)) return { results: [] };
|
|
36
|
+
return o && manifest?.objects?.[o] ? sampleObject(manifest, o) : {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ports for previews. `ports.call` answers from `fixtures` (key "<module key>.<capability>", a value or a function of
|
|
41
|
+
* the args), else from the manifest's own output shape, and records the call. The real `callApi` the base ports would
|
|
42
|
+
* use throws, so a call that somehow bypassed the recorder fails loudly instead of reaching an API.
|
|
43
|
+
*
|
|
44
|
+
* @param {{ def, manifests, fixtures?: object, store?: Map }} p fixtures.store pre-seeds the link store (keys as the real store's)
|
|
45
|
+
* @returns {{ ports, calls: object[], refusing: object }} `refusing`: the unwrapped ports, whose call() always throws (kept so a test can prove it)
|
|
46
|
+
*/
|
|
47
|
+
function recordingPorts({ def, manifests, fixtures = {}, store }) {
|
|
48
|
+
const calls = [];
|
|
49
|
+
const rows = store || new Map(Object.entries(clone(fixtures.store) || {}));
|
|
50
|
+
const mem = {
|
|
51
|
+
get: async (k) => rows.get(k) ?? null,
|
|
52
|
+
put: async (k, v) => { rows.set(k, v); },
|
|
53
|
+
claim: async (k, v) => { if (rows.has(k)) return false; rows.set(k, v); return true; },
|
|
54
|
+
release: async (k) => { rows.delete(k); },
|
|
55
|
+
};
|
|
56
|
+
const base = makePorts({
|
|
57
|
+
def, manifests, store: mem,
|
|
58
|
+
callApi: async (moduleKey, method) => { throw new DryRunError(`${moduleKey}.${method}`); },
|
|
59
|
+
runCode: async (ref, args) => answer('code', ref, 'code', args, fixtures[`code:${ref}`], {}),
|
|
60
|
+
});
|
|
61
|
+
const ports = { ...base }; // the seam below replaces call() on this copy; `base` keeps the one that reaches callApi
|
|
62
|
+
const manifestFor = (key) => manifests[def.modules[key]?.module];
|
|
63
|
+
async function answer(module, capability, kind, args, fixture, fallback) {
|
|
64
|
+
const call = { module, capability, kind, args: clone(args) };
|
|
65
|
+
calls.push(call);
|
|
66
|
+
try {
|
|
67
|
+
call.output = typeof fixture === 'function' ? clone(await fixture(clone(args), { module, capability })) : fixture !== undefined ? clone(fixture) : fallback;
|
|
68
|
+
return clone(call.output);
|
|
69
|
+
} catch (e) { call.error = String(e.message || e); throw e; }
|
|
70
|
+
}
|
|
71
|
+
// `refusing` is the base ports as they were before the seam below: their call() reaches callApi, which throws.
|
|
72
|
+
// The seam: the interpreter only ever reaches a module through ports.call.
|
|
73
|
+
ports.call = async (moduleKey, capability, args) => {
|
|
74
|
+
const m = manifestFor(moduleKey);
|
|
75
|
+
const c = m?.capabilities?.[capability];
|
|
76
|
+
if (!c) {
|
|
77
|
+
calls.push({ module: moduleKey, capability, kind: 'unknown', args: clone(args), error: 'not declared by the module manifest' });
|
|
78
|
+
throw new Error(`${moduleKey}.${capability} is not declared by the module manifest`);
|
|
79
|
+
}
|
|
80
|
+
return answer(moduleKey, capability, c.kind, args, fixtures[`${moduleKey}.${capability}`], sampleOutput(m, c));
|
|
81
|
+
};
|
|
82
|
+
return { ports, calls, refusing: base };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** read/search/subscribe calls fetch; the rest (and code) is what leaves the integration. */
|
|
86
|
+
const isSend = (call) => call.kind === 'write' || call.kind === 'delete';
|
|
87
|
+
|
|
88
|
+
/** How one outbound call would look where it lands. Slack messages get the real Block Kit/mrkdwn rendering. */
|
|
89
|
+
function renderCall(call, manifest, appName) {
|
|
90
|
+
if (manifest?.module === 'slack' && /^message\./.test(call.capability)) {
|
|
91
|
+
const a = call.args || {};
|
|
92
|
+
const slack = { channel: a.channel, text: a.text, ...(Array.isArray(a.blocks) ? { blocks: a.blocks } : {}) };
|
|
93
|
+
return { html: slackMessageToHtml({ ...slack, appName }), slack };
|
|
94
|
+
}
|
|
95
|
+
return { html: callToHtml(call) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Dry-run one workflow. Nothing is performed: every capability call is recorded with the args it would carry and
|
|
100
|
+
* answered from `fixtures` (or an empty record shaped by the manifest). A webhook workflow with no `event` runs on
|
|
101
|
+
* the manifest's own sample payload for its trigger.
|
|
102
|
+
*
|
|
103
|
+
* @param {{ def, installation?, workflowId: string, event?, record?, manifests, fixtures?: object, now?: Function }} p
|
|
104
|
+
* @returns {Promise<{ workflow: string, status: string, reason?: string, error?: string, waiting?: object, sends: object[], calls: object[], trace: object[] }>}
|
|
105
|
+
* `calls`: every call in order, `{ stepId, at, module, capability, kind, args, output, rendered }`; `sends`: the writes
|
|
106
|
+
* among them (`rendered` is `{ html, slack? }` for those, null for reads)
|
|
107
|
+
*/
|
|
108
|
+
async function previewWorkflow({ def, installation = {}, workflowId, event, record, manifests, fixtures = {}, now }) {
|
|
109
|
+
const w = (def.workflows || []).find((x) => x.id === workflowId);
|
|
110
|
+
if (!w) throw new Error(`Unknown workflow "${workflowId}"`);
|
|
111
|
+
if (event === undefined) {
|
|
112
|
+
const t = w.trigger;
|
|
113
|
+
event = (t.type === 'webhook' && clone(manifests[def.modules[t.module]?.module]?.events?.[t.topic]?.payload)) || {};
|
|
114
|
+
}
|
|
115
|
+
const { ports, calls } = recordingPorts({ def, manifests, fixtures });
|
|
116
|
+
const run = await runWorkflow({ def, installation, workflowId, event, record, ports, manifests, ...(now ? { now } : {}) });
|
|
117
|
+
|
|
118
|
+
// The trace has one entry per step that ran; the calls, in the same order, belong to the entries that are capability steps (or code).
|
|
119
|
+
const callSteps = (run.trace || []).filter((t) => (!isBuiltin(t.use) || t.use === 'code') && (t.status === 'ok' || t.status === 'error'));
|
|
120
|
+
const aligned = callSteps.length === calls.length;
|
|
121
|
+
const out = calls.map((c, i) => {
|
|
122
|
+
const manifest = manifests[def.modules[c.module]?.module];
|
|
123
|
+
return { stepId: aligned ? callSteps[i].id : null, at: aligned ? callSteps[i].at : null, ...c, rendered: isSend(c) && manifest ? renderCall(c, manifest, def.label || def.id) : null };
|
|
124
|
+
});
|
|
125
|
+
const waiting = run.status === 'waiting' ? run.waitingFor : undefined;
|
|
126
|
+
return {
|
|
127
|
+
workflow: workflowId, status: run.status, ...(run.reason ? { reason: run.reason } : {}), ...(run.error ? { error: run.error } : {}),
|
|
128
|
+
...(waiting ? { waiting: { ...waiting, ...(waiting.kind === 'approval' ? { html: `<div style="${HOSTS.generic.frame}"><div style="${HOSTS.generic.head}">Waits for approval</div><div style="${HOSTS.generic.body}">${esc(waiting.prompt)}</div></div>` } : {}) } } : {}),
|
|
129
|
+
sends: out.filter(isSend), calls: out, trace: run.trace || [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render one UI surface. `ports` answer the surface's query (real ports for real records; `recordingPorts(...).ports`
|
|
135
|
+
* with fixtures for samples). `host` defaults to the surface's own.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<{ surface, view, html: string, slack?: { blocks, text, truncated }, hubspot?: object }>}
|
|
138
|
+
*/
|
|
139
|
+
async function previewSurface({ def, installation = {}, surfaceId, record, ports, host }) {
|
|
140
|
+
const { surface, view } = await renderSurface({ def, installation, surfaceId, record, ports });
|
|
141
|
+
const h = host || surface.host;
|
|
142
|
+
const opts = { integrationId: installation.integrationId, surface: surface.id };
|
|
143
|
+
const out = { surface, view };
|
|
144
|
+
if (h === 'slack') {
|
|
145
|
+
out.slack = viewToSlack(view, opts);
|
|
146
|
+
out.html = `<div data-preview-host="slack" style="${HOSTS.slack.frame}"><div style="${HOSTS.slack.head}">${esc(surface.label || def.label || def.id)} · ${esc(surface.placement)}</div><div style="${HOSTS.slack.body}">${view ? slackBlocksToHtml(out.slack.blocks) : '<em>Nothing to show for this record.</em>'}</div></div>`;
|
|
147
|
+
} else {
|
|
148
|
+
if (h === 'hubspot') out.hubspot = viewToHubSpot(view, opts);
|
|
149
|
+
out.html = viewToHtml(view, { host: h, title: `${def.ui.find((u) => u.id === surface.id)?.label || surface.id} · ${surface.placement}` });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { previewSurface, previewWorkflow, recordingPorts, DryRunError, recordOf };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved view tree -> Slack Block Kit. Data comes from vendor records, so everything that is data is escaped for
|
|
3
|
+
* mrkdwn, and everything is cut to Slack's limits with a visible marker instead of letting chat.postMessage reject the
|
|
4
|
+
* whole message (a long deal name must not cost the approval request).
|
|
5
|
+
*/
|
|
6
|
+
const { str, safeUrl, truncate, toneOf, encodeAction } = require('./common');
|
|
7
|
+
|
|
8
|
+
const LIMITS = { blocks: 50, header: 150, sectionText: 3000, fieldText: 2000, fields: 10, contextText: 3000, contextElements: 10, button: 75, buttonUrl: 3000, actionId: 255, actions: 25, listItems: 25, itemText: 300 };
|
|
9
|
+
const MARK = '…';
|
|
10
|
+
const ZWSP = '\u200b';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Text that is DATA, for a mrkdwn field. `&`, `<` and `>` are the three characters Slack requires escaping (this also
|
|
14
|
+
* defuses `<!channel>` and `<https://evil|click>` in a vendor field). Slack has no backslash escape for `* _ ~ \``, so
|
|
15
|
+
* `* _ ~` are fenced with a zero-width space, which stops them pairing into bold/italic/strike without changing how they
|
|
16
|
+
* look. A fenced backtick would still pair into a code span, so it becomes the look-alike U+02CB.
|
|
17
|
+
*/
|
|
18
|
+
function escapeMrkdwn(v) {
|
|
19
|
+
return str(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/[*_~]/g, `${ZWSP}$&${ZWSP}`).replace(/`/g, '\u02cb');
|
|
20
|
+
}
|
|
21
|
+
const mrkdwn = (t) => ({ type: 'mrkdwn', text: t });
|
|
22
|
+
const plain = (t) => ({ type: 'plain_text', text: t, emoji: false });
|
|
23
|
+
const clip = (s, max) => truncate(s, max, MARK);
|
|
24
|
+
|
|
25
|
+
/** Lines packed into as few sections as fit 3000 characters; one line is never split. */
|
|
26
|
+
function linesToSections(lines) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let cur = '';
|
|
29
|
+
for (const l of lines) {
|
|
30
|
+
const line = clip(l, LIMITS.itemText);
|
|
31
|
+
if (cur && cur.length + 1 + line.length > LIMITS.sectionText) { out.push(cur); cur = ''; }
|
|
32
|
+
cur = cur ? `${cur}\n${line}` : line;
|
|
33
|
+
}
|
|
34
|
+
if (cur) out.push(cur);
|
|
35
|
+
return out.map((text) => ({ type: 'section', text: mrkdwn(clip(text, LIMITS.sectionText)) }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TONE_EMOJI = { positive: ':large_green_circle:', warning: ':large_yellow_circle:', critical: ':red_circle:', neutral: ':white_circle:' };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object|null} view resolveView output ({ component, props, children })
|
|
42
|
+
* @param {{ integrationId?: string, surface?: string }} [opts] what a button's click carries back
|
|
43
|
+
* @returns {{ blocks: object[], text: string, truncated: boolean }} `text` is the notification/fallback text
|
|
44
|
+
*/
|
|
45
|
+
function viewToSlack(view, { integrationId, surface } = {}) {
|
|
46
|
+
const blocks = [];
|
|
47
|
+
let text = '';
|
|
48
|
+
let truncated = false;
|
|
49
|
+
let pending = []; // consecutive buttons share one actions block
|
|
50
|
+
let n = 0;
|
|
51
|
+
|
|
52
|
+
const flush = () => {
|
|
53
|
+
if (!pending.length) return;
|
|
54
|
+
const els = pending.slice(0, LIMITS.actions);
|
|
55
|
+
if (pending.length > LIMITS.actions) truncated = true;
|
|
56
|
+
blocks.push({ type: 'actions', elements: els });
|
|
57
|
+
pending = [];
|
|
58
|
+
};
|
|
59
|
+
const push = (b) => { flush(); blocks.push(b); };
|
|
60
|
+
|
|
61
|
+
const walk = (node) => {
|
|
62
|
+
if (!node || typeof node !== 'object') return;
|
|
63
|
+
const p = node.props || {};
|
|
64
|
+
switch (node.component) {
|
|
65
|
+
case 'Heading': {
|
|
66
|
+
flush();
|
|
67
|
+
const t = clip(str(p.text), LIMITS.header);
|
|
68
|
+
if (!text) text = t;
|
|
69
|
+
if (t) blocks.push({ type: 'header', text: plain(t) });
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'Text': {
|
|
73
|
+
const t = str(p.text);
|
|
74
|
+
if (!text) text = t;
|
|
75
|
+
if (t) push({ type: 'section', text: mrkdwn(clip(escapeMrkdwn(t), LIMITS.sectionText)) });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'KeyValue': {
|
|
79
|
+
const items = Array.isArray(p.items) ? p.items : [];
|
|
80
|
+
for (let i = 0; i < items.length; i += LIMITS.fields) {
|
|
81
|
+
const fields = items.slice(i, i + LIMITS.fields).map((it) => mrkdwn(clip(`*${escapeMrkdwn(it?.label)}*\n${escapeMrkdwn(it?.value) || '—'}`, LIMITS.fieldText)));
|
|
82
|
+
if (fields.length) push({ type: 'section', fields });
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case 'List': {
|
|
87
|
+
const items = Array.isArray(p.items) ? p.items : [];
|
|
88
|
+
const lines = items.slice(0, LIMITS.listItems).map((it) => `• ${escapeMrkdwn(it?.title)}${it?.meta ? ` — _${escapeMrkdwn(it.meta)}_` : ''}`);
|
|
89
|
+
if (items.length > LIMITS.listItems) { lines.push(`_${MARK} and ${items.length - LIMITS.listItems} more_`); truncated = true; }
|
|
90
|
+
flush();
|
|
91
|
+
blocks.push(...linesToSections(lines));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'Table': {
|
|
95
|
+
const cols = Array.isArray(p.columns) ? p.columns : [];
|
|
96
|
+
const rows = Array.isArray(p.rows) ? p.rows : [];
|
|
97
|
+
const lines = [];
|
|
98
|
+
if (cols.length) lines.push(cols.map((c) => `*${escapeMrkdwn(c)}*`).join(' | '));
|
|
99
|
+
for (const r of rows.slice(0, LIMITS.listItems)) lines.push((Array.isArray(r) ? r : []).map((c) => escapeMrkdwn(c) || '—').join(' | '));
|
|
100
|
+
if (rows.length > LIMITS.listItems) { lines.push(`_${MARK} and ${rows.length - LIMITS.listItems} more_`); truncated = true; }
|
|
101
|
+
flush();
|
|
102
|
+
blocks.push(...linesToSections(lines));
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'Badge': {
|
|
106
|
+
const t = str(p.text);
|
|
107
|
+
if (t) push({ type: 'context', elements: [mrkdwn(clip(`${TONE_EMOJI[toneOf(p.tone)]} *${escapeMrkdwn(t)}*`, LIMITS.contextText))] });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'Empty': {
|
|
111
|
+
const t = str(p.text);
|
|
112
|
+
if (t) push({ type: 'context', elements: [mrkdwn(clip(`_${escapeMrkdwn(t)}_`, LIMITS.contextText))] });
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'Divider': push({ type: 'divider' }); break;
|
|
116
|
+
case 'Button': {
|
|
117
|
+
const label = clip(str(p.label) || 'Open', LIMITS.button);
|
|
118
|
+
const style = p.style === 'primary' ? { style: 'primary' } : p.style === 'danger' ? { style: 'danger' } : {};
|
|
119
|
+
const a = p.action;
|
|
120
|
+
if (a?.type === 'userAction' && typeof a.workflow === 'string') {
|
|
121
|
+
const value = encodeAction({ integrationId, workflow: a.workflow, surface, needsInput: !!a.input });
|
|
122
|
+
if (value) pending.push({ type: 'button', text: plain(label), action_id: clip(`frigg:${a.workflow}`, LIMITS.actionId - 8) + `:${n++}`, value, ...style });
|
|
123
|
+
} else if (a?.type === 'open_url') {
|
|
124
|
+
const url = safeUrl(a.url);
|
|
125
|
+
if (url && url.length <= LIMITS.buttonUrl) pending.push({ type: 'button', text: plain(label), action_id: `frigg:open:${n++}`, url });
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
default: break; // Stack, and anything a newer definition adds: just its children
|
|
130
|
+
}
|
|
131
|
+
for (const c of node.children || []) walk(c);
|
|
132
|
+
};
|
|
133
|
+
walk(view);
|
|
134
|
+
flush();
|
|
135
|
+
|
|
136
|
+
// 50 blocks is Slack's whole-message cap. Keep 49 and say so, rather than lose the message.
|
|
137
|
+
let out = blocks;
|
|
138
|
+
if (blocks.length > LIMITS.blocks) {
|
|
139
|
+
out = [...blocks.slice(0, LIMITS.blocks - 1), { type: 'context', elements: [mrkdwn(`_${MARK} ${blocks.length - (LIMITS.blocks - 1)} more blocks not shown_`)] }];
|
|
140
|
+
truncated = true;
|
|
141
|
+
}
|
|
142
|
+
// `text` is the notification fallback and Slack parses it as mrkdwn too, so a record's `<!channel>` must not ping from there either.
|
|
143
|
+
return { blocks: out, text: clip(escapeMrkdwn(text || 'Update'), LIMITS.sectionText), truncated };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { viewToSlack, escapeMrkdwn, LIMITS };
|
|
@@ -21,6 +21,21 @@ const { TRANSFORMS } = require('./transforms');
|
|
|
21
21
|
* Backed by IntegrationMapping: get = getMapping, put = upsertMapping, claim = createMapping (insert-only; false if it existed)
|
|
22
22
|
* @param {(ref:string, args:object) => Promise<any>} [p.runCode]
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The arguments an Api method is called with. A capability with no `args` takes one object holding every input; one
|
|
26
|
+
* with `args` (manifest `args`) takes them positionally: an input by name, "$rest" (an object of the inputs not
|
|
27
|
+
* named in the list) or { value } (a constant, e.g. the sObject name of Salesforce's generic create/get).
|
|
28
|
+
*/
|
|
29
|
+
function argumentsFor(input, order) {
|
|
30
|
+
if (!Array.isArray(order)) return [input];
|
|
31
|
+
const named = new Set(order.filter((a) => typeof a === 'string' && a !== '$rest'));
|
|
32
|
+
return order.map((a) => {
|
|
33
|
+
if (a === '$rest') return Object.fromEntries(Object.entries(input || {}).filter(([k]) => !named.has(k)));
|
|
34
|
+
if (a && typeof a === 'object') return a.value;
|
|
35
|
+
return input?.[a];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
function makePorts({ def, manifests, callApi, store, runCode }) {
|
|
25
40
|
const manifestFor = (key) => manifests[def.modules[key]?.module];
|
|
26
41
|
const normalise = (set, key, value) => {
|
|
@@ -39,7 +54,7 @@ function makePorts({ def, manifests, callApi, store, runCode }) {
|
|
|
39
54
|
async call(moduleKey, capability, args) {
|
|
40
55
|
const c = manifestFor(moduleKey)?.capabilities?.[capability];
|
|
41
56
|
if (!c) throw new Error(`${moduleKey}.${capability} is not declared by the module manifest`);
|
|
42
|
-
return callApi(moduleKey, c.method, args);
|
|
57
|
+
return callApi(moduleKey, c.method, args, c.args);
|
|
43
58
|
},
|
|
44
59
|
links: {
|
|
45
60
|
/** By one side's id: link:<set>:<module>:<id>. By identity key: idx:<set>:<key>:<normalised value>. */
|
|
@@ -250,4 +265,4 @@ function defaultsOf(settings) {
|
|
|
250
265
|
return Object.fromEntries(Object.entries(settings || {}).filter(([, s]) => s.default !== undefined).map(([k, s]) => [k, s.default]));
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
module.exports = { makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, resolveView, runLifecycle, resolveOptions, installContext, recordOf, defaultsOf };
|
|
268
|
+
module.exports = { argumentsFor, makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, resolveView, runLifecycle, resolveOptions, installContext, recordOf, defaultsOf };
|
|
@@ -411,6 +411,15 @@
|
|
|
411
411
|
"body": {}
|
|
412
412
|
},
|
|
413
413
|
"description": "Answered at receipt, before anything is queued (Slack url_verification, Asana X-Hook-Secret)."
|
|
414
|
+
},
|
|
415
|
+
"status": {
|
|
416
|
+
"enum": [
|
|
417
|
+
"unimplemented"
|
|
418
|
+
],
|
|
419
|
+
"description": "The module's Api has no `verify` method (or none that checks a signature). Every delivery fails verification until it does, so definitions that trigger on this module's events validate with a warning."
|
|
420
|
+
},
|
|
421
|
+
"note": {
|
|
422
|
+
"type": "string"
|
|
414
423
|
}
|
|
415
424
|
}
|
|
416
425
|
},
|
|
@@ -445,6 +454,37 @@
|
|
|
445
454
|
},
|
|
446
455
|
"output": {
|
|
447
456
|
"type": "string"
|
|
457
|
+
},
|
|
458
|
+
"args": {
|
|
459
|
+
"type": "array",
|
|
460
|
+
"description": "How the Api method takes its arguments, when it is not one object holding every input. Each entry is an input name (passed positionally), \"$rest\" (an object of every input not named elsewhere in the list) or {\"value\": x} (a constant, such as the sObject name of a generic Api). Omitted: the method is called with one object of all inputs.",
|
|
461
|
+
"items": {
|
|
462
|
+
"oneOf": [
|
|
463
|
+
{
|
|
464
|
+
"type": "string"
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"type": "object",
|
|
468
|
+
"required": [
|
|
469
|
+
"value"
|
|
470
|
+
],
|
|
471
|
+
"additionalProperties": false,
|
|
472
|
+
"properties": {
|
|
473
|
+
"value": {}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
"status": {
|
|
480
|
+
"enum": [
|
|
481
|
+
"unimplemented"
|
|
482
|
+
],
|
|
483
|
+
"description": "The module's real Api class has no method that does this (or none shaped so the call can be made). Definitions that use it validate with a warning, never an error."
|
|
484
|
+
},
|
|
485
|
+
"note": {
|
|
486
|
+
"type": "string",
|
|
487
|
+
"description": "What the real method does differently, or what is missing upstream."
|
|
448
488
|
}
|
|
449
489
|
}
|
|
450
490
|
}
|