@firedrill-tools/unified 0.1.1
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 +201 -0
- package/README.md +242 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounds.scenario.json +23 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/large-channels.scenario.json +4251 -0
- package/firedrill/many-workspaces.scenario.json +1623 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/unified/behavior.mjs +69 -0
- package/firedrill/tools/unified/lib/assoc.mjs +61 -0
- package/firedrill/tools/unified/lib/enums.mjs +14 -0
- package/firedrill/tools/unified/lib/errors.mjs +50 -0
- package/firedrill/tools/unified/lib/events.mjs +27 -0
- package/firedrill/tools/unified/lib/identity.mjs +59 -0
- package/firedrill/tools/unified/lib/query.mjs +154 -0
- package/firedrill/tools/unified/lib/shapes.mjs +176 -0
- package/firedrill/tools/unified/lib/store.mjs +75 -0
- package/firedrill/tools/unified/lib/time.mjs +59 -0
- package/firedrill/tools/unified/lib/util.mjs +92 -0
- package/firedrill/tools/unified/lib/validate.mjs +114 -0
- package/firedrill/tools/unified/lib/wire.mjs +120 -0
- package/firedrill/tools/unified/ops/channels.mjs +45 -0
- package/firedrill/tools/unified/ops/connections.mjs +151 -0
- package/firedrill/tools/unified/ops/crm.mjs +126 -0
- package/firedrill/tools/unified/ops/deals.mjs +123 -0
- package/firedrill/tools/unified/ops/messages.mjs +133 -0
- package/firedrill/tools/unified/ops/pipelines.mjs +20 -0
- package/firedrill/tools/unified/ops/resource.mjs +114 -0
- package/firedrill/tools/unified/unified.tool.json +14068 -0
- package/firedrill/unified-bounds.drill.json +148 -0
- package/firedrill/unified-connections-flow.drill.json +226 -0
- package/firedrill/unified-crm-flow.drill.json +576 -0
- package/firedrill/unified-denied.drill.json +83 -0
- package/firedrill/unified-error-coverage.drill.json +528 -0
- package/firedrill/unified-fresh-actor.drill.json +83 -0
- package/firedrill/unified-invalid-workspace.drill.json +933 -0
- package/firedrill/unified-large-channels.drill.json +68 -0
- package/firedrill/unified-many-workspaces.drill.json +943 -0
- package/firedrill/unified-mcp-core-aliases.drill.json +113 -0
- package/firedrill/unified-messaging-flow.drill.json +326 -0
- package/firedrill/unified-rate-limited.drill.json +933 -0
- package/firedrill/unified-scoped-token.drill.json +113 -0
- package/firedrill/unified-size-bounds.drill.json +263 -0
- package/firedrill/unified-write-committed-lost.drill.json +107 -0
- package/firedrill/unified-write-unavailable.drill.json +528 -0
- package/firedrill/world.json +2958 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +2446 -0
- package/test/conformance.mjs +39 -0
- package/test/flows/connections.mjs +87 -0
- package/test/flows/coverage.mjs +64 -0
- package/test/flows/crm.mjs +124 -0
- package/test/flows/faults.mjs +82 -0
- package/test/flows/identity.mjs +84 -0
- package/test/flows/messaging.mjs +82 -0
- package/test/flows/size.mjs +71 -0
- package/test/lib.mjs +133 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Synthetic Unified.to workspace for Firedrill: connections, unified CRM (contact, company, deal, pipeline) and
|
|
2
|
+
// Messaging (channel, message). Every operation computes from `context.state`; ids come from `meta/counters`,
|
|
3
|
+
// timestamps from virtual time. No Unified.to service and no underlying platform is contacted.
|
|
4
|
+
import { Q, decoder, encoder } from "./lib/wire.mjs";
|
|
5
|
+
import { channels } from "./ops/channels.mjs";
|
|
6
|
+
import { connections } from "./ops/connections.mjs";
|
|
7
|
+
import { companies, contacts } from "./ops/crm.mjs";
|
|
8
|
+
import { deals } from "./ops/deals.mjs";
|
|
9
|
+
import { messages } from "./ops/messages.mjs";
|
|
10
|
+
import { pipelines } from "./ops/pipelines.mjs";
|
|
11
|
+
|
|
12
|
+
const LIST = { limit: Q.int, offset: Q.int, updated_gte: Q.str, sort: Q.str, order: Q.str, query: Q.str, fields: Q.list };
|
|
13
|
+
const FIELDS = { fields: Q.list };
|
|
14
|
+
const CONNECTION = ["connection_id"];
|
|
15
|
+
const CONNECTION_ID = ["connection_id", "id"];
|
|
16
|
+
|
|
17
|
+
const listRoute = (extra) => decoder({ path: CONNECTION, query: { ...LIST, ...extra } });
|
|
18
|
+
const getRoute = () => decoder({ path: CONNECTION_ID, query: FIELDS });
|
|
19
|
+
const createRoute = () => decoder({ path: CONNECTION, body: "json", idem: true });
|
|
20
|
+
const updateRoute = () => decoder({ path: CONNECTION_ID, body: "json", idem: true });
|
|
21
|
+
const removeRoute = () => decoder({ path: CONNECTION_ID, idem: true });
|
|
22
|
+
|
|
23
|
+
/** [operation id, handler, [route id, decoder]...] */
|
|
24
|
+
const TABLE = [
|
|
25
|
+
["connections.list", connections.list, ["list-connections", decoder({ query: { limit: Q.int, offset: Q.int, updated_gte: Q.str, sort: Q.str, order: Q.str, env: Q.str, categories: Q.list, external_xref: Q.str } })]],
|
|
26
|
+
["connections.get", connections.get, ["get-connection", decoder({ path: ["id"] })]],
|
|
27
|
+
["connections.create", connections.create, ["create-connection", decoder({ body: "json", idem: true })]],
|
|
28
|
+
["connections.update", connections.update, ["update-connection-put", decoder({ path: ["id"], body: "json", idem: true })], ["update-connection-patch", decoder({ path: ["id"], body: "json", idem: true })]],
|
|
29
|
+
["connections.remove", connections.remove, ["remove-connection", decoder({ path: ["id"], idem: true })]],
|
|
30
|
+
|
|
31
|
+
["contacts.list", contacts.list, ["list-crm-contacts", listRoute({ company_id: Q.str, deal_id: Q.str, user_id: Q.str })]],
|
|
32
|
+
["contacts.get", contacts.get, ["get-crm-contact", getRoute()]],
|
|
33
|
+
["contacts.create", contacts.create, ["create-crm-contact", createRoute()]],
|
|
34
|
+
["contacts.update", contacts.update, ["update-crm-contact-put", updateRoute()], ["update-crm-contact-patch", updateRoute()]],
|
|
35
|
+
["contacts.remove", contacts.remove, ["remove-crm-contact", removeRoute()]],
|
|
36
|
+
|
|
37
|
+
["companies.list", companies.list, ["list-crm-companies", listRoute({ contact_id: Q.str, deal_id: Q.str, user_id: Q.str })]],
|
|
38
|
+
["companies.get", companies.get, ["get-crm-company", getRoute()]],
|
|
39
|
+
["companies.create", companies.create, ["create-crm-company", createRoute()]],
|
|
40
|
+
["companies.update", companies.update, ["update-crm-company-put", updateRoute()], ["update-crm-company-patch", updateRoute()]],
|
|
41
|
+
["companies.remove", companies.remove, ["remove-crm-company", removeRoute()]],
|
|
42
|
+
|
|
43
|
+
["deals.list", deals.list, ["list-crm-deals", listRoute({ company_id: Q.str, contact_id: Q.str, user_id: Q.str, pipeline_id: Q.str })]],
|
|
44
|
+
["deals.get", deals.get, ["get-crm-deal", getRoute()]],
|
|
45
|
+
["deals.create", deals.create, ["create-crm-deal", createRoute()]],
|
|
46
|
+
["deals.update", deals.update, ["update-crm-deal-put", updateRoute()], ["update-crm-deal-patch", updateRoute()]],
|
|
47
|
+
["deals.remove", deals.remove, ["remove-crm-deal", removeRoute()]],
|
|
48
|
+
|
|
49
|
+
["pipelines.list", pipelines.list, ["list-crm-pipelines", decoder({ path: CONNECTION, query: { limit: Q.int, offset: Q.int, updated_gte: Q.str, sort: Q.str, order: Q.str, fields: Q.list } })]],
|
|
50
|
+
["pipelines.get", pipelines.get, ["get-crm-pipeline", getRoute()]],
|
|
51
|
+
|
|
52
|
+
["channels.list", channels.list, ["list-messaging-channels", listRoute({ parent_id: Q.str, type: Q.str })]],
|
|
53
|
+
["channels.get", channels.get, ["get-messaging-channel", getRoute()]],
|
|
54
|
+
|
|
55
|
+
["messages.list", messages.list, ["list-messaging-messages", listRoute({ channel_id: Q.str, parent_id: Q.str, type: Q.str, start_gte: Q.str, end_lt: Q.str, expand: Q.str, user_id: Q.str, user_mentioned_id: Q.str })]],
|
|
56
|
+
["messages.get", messages.get, ["get-messaging-message", getRoute()]],
|
|
57
|
+
["messages.create", messages.create, ["create-messaging-message", createRoute()]],
|
|
58
|
+
["messages.update", messages.update, ["update-messaging-message-put", updateRoute()], ["update-messaging-message-patch", updateRoute()]],
|
|
59
|
+
["messages.remove", messages.remove, ["remove-messaging-message", removeRoute()]],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const operations = {};
|
|
63
|
+
const http = {};
|
|
64
|
+
for (const [operationId, handler, ...routes] of TABLE) {
|
|
65
|
+
operations[operationId] = handler;
|
|
66
|
+
for (const [routeId, decode] of routes) http[routeId] = { decode, encode: encoder };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default { operations, http };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Symmetric CRM associations: a contact's `company_ids` mirrors each company's `contact_ids`, and so on. Only rows
|
|
2
|
+
// whose membership actually changes are rewritten (each rewrite emits object.updated for that row). References are
|
|
3
|
+
// checked with point lookups by id, never by rescanning the namespace.
|
|
4
|
+
import { badRequest } from "./errors.mjs";
|
|
5
|
+
import { emitUpdated } from "./events.mjs";
|
|
6
|
+
import { getRow, putRow } from "./store.mjs";
|
|
7
|
+
import { nowIso } from "./time.mjs";
|
|
8
|
+
import { clip } from "./util.mjs";
|
|
9
|
+
|
|
10
|
+
/** namespace -> { objectType, links: { field: [target namespace, reverse field] } } */
|
|
11
|
+
export const CRM = {
|
|
12
|
+
contacts: { objectType: "crm_contact", label: "Contact", links: { company_ids: ["companies", "contact_ids"], deal_ids: ["deals", "contact_ids"] } },
|
|
13
|
+
companies: { objectType: "crm_company", label: "Company", links: { contact_ids: ["contacts", "company_ids"], deal_ids: ["deals", "company_ids"] } },
|
|
14
|
+
deals: { objectType: "crm_deal", label: "Deal", links: { contact_ids: ["contacts", "deal_ids"], company_ids: ["companies", "deal_ids"] } },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const LABEL = { contacts: "contact", companies: "company", deals: "deal" };
|
|
18
|
+
|
|
19
|
+
/** Every referenced id must be a row of the same connection. */
|
|
20
|
+
export function checkReferences(context, connectionId, namespace, fields) {
|
|
21
|
+
for (const [field, [target]] of Object.entries(CRM[namespace].links)) {
|
|
22
|
+
const ids = fields[field];
|
|
23
|
+
if (!Array.isArray(ids)) continue;
|
|
24
|
+
for (const id of ids) {
|
|
25
|
+
if (getRow(context, target, connectionId, id) === null) badRequest(context, `Unknown ${LABEL[target]} id in ${field}: ${clip(id, 40)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rewrite(context, connection, target, row, reverseField, ids) {
|
|
31
|
+
const next = { ...row, [reverseField]: ids, updated_at: nowIso(context) };
|
|
32
|
+
putRow(context, target, next);
|
|
33
|
+
emitUpdated(context, connection, CRM[target].objectType, row.id, [reverseField]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Adds `ownId` to the reverse arrays of newly linked rows and removes it from rows no longer linked. */
|
|
37
|
+
export function syncAssociations(context, connection, namespace, row, previous) {
|
|
38
|
+
for (const [field, [target, reverseField]] of Object.entries(CRM[namespace].links)) {
|
|
39
|
+
const before = new Set(previous === null ? [] : previous[field] ?? []);
|
|
40
|
+
const after = new Set(row[field] ?? []);
|
|
41
|
+
for (const id of after) {
|
|
42
|
+
if (before.has(id)) continue;
|
|
43
|
+
const other = getRow(context, target, connection.id, id);
|
|
44
|
+
if (other === null) continue;
|
|
45
|
+
const ids = other[reverseField] ?? [];
|
|
46
|
+
if (!ids.includes(row.id)) rewrite(context, connection, target, other, reverseField, [...ids, row.id]);
|
|
47
|
+
}
|
|
48
|
+
for (const id of before) {
|
|
49
|
+
if (after.has(id)) continue;
|
|
50
|
+
const other = getRow(context, target, connection.id, id);
|
|
51
|
+
if (other === null) continue;
|
|
52
|
+
const ids = other[reverseField] ?? [];
|
|
53
|
+
if (ids.includes(row.id)) rewrite(context, connection, target, other, reverseField, ids.filter((entry) => entry !== row.id));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Removes a deleted row's id from every associated row. */
|
|
59
|
+
export function detachAll(context, connection, namespace, row) {
|
|
60
|
+
syncAssociations(context, connection, namespace, { ...row, contact_ids: [], company_ids: [], deal_ids: [] }, row);
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Connection enums as published in the provider's OpenAPI document (property_Connection_categories and the crm_/
|
|
2
|
+
// messaging_ values of property_Connection_permissions).
|
|
3
|
+
export const CATEGORIES = [
|
|
4
|
+
"passthrough", "hris", "ats", "auth", "saml", "crm", "enrich", "martech", "ticketing", "uc", "accounting", "storage", "commerce",
|
|
5
|
+
"payment", "genai", "messaging", "kms", "task", "scim", "lms", "repo", "metadata", "calendar", "verification", "ads", "analytics",
|
|
6
|
+
"forms", "shipping", "assessment", "signing", "clubs", "datastore", "cdp", "performance", "social",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export const PERMISSIONS = [
|
|
10
|
+
"crm_company_read", "crm_company_write", "crm_contact_read", "crm_contact_write", "crm_deal_read", "crm_deal_write",
|
|
11
|
+
"crm_event_read", "crm_event_write", "crm_lead_read", "crm_lead_write", "crm_pipeline_read", "crm_pipeline_write",
|
|
12
|
+
"crm_taxonomy_read", "messaging_message_read", "messaging_message_write", "messaging_channel_read", "messaging_channel_write",
|
|
13
|
+
"messaging_event_read", "messaging_event_write",
|
|
14
|
+
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Declared Tool error codes, their HTTP status on every route and the reason phrases of the wire envelope.
|
|
2
|
+
import { clip } from "./util.mjs";
|
|
3
|
+
|
|
4
|
+
/** code -> [HTTP status, reason phrase] */
|
|
5
|
+
export const ERRORS = new Map([
|
|
6
|
+
["BAD_REQUEST", [400, "Bad Request"]],
|
|
7
|
+
["UNAUTHORIZED", [401, "Unauthorized"]],
|
|
8
|
+
["FORBIDDEN", [403, "Forbidden"]],
|
|
9
|
+
["NOT_FOUND", [404, "Not Found"]],
|
|
10
|
+
["PAYLOAD_TOO_LARGE", [413, "Payload Too Large"]],
|
|
11
|
+
["RATE_LIMITED", [429, "Too Many Requests"]],
|
|
12
|
+
["INTERNAL_ERROR", [500, "Internal Server Error"]],
|
|
13
|
+
["FAILED_PRECONDITION", [500, "Internal Server Error"]],
|
|
14
|
+
["NOT_IMPLEMENTED", [501, "Not Implemented"]],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Encoded page budget in UTF-8 bytes; the framework refuses route responses above 1 MiB. */
|
|
18
|
+
export const PAGE_BYTE_BUDGET = 900000;
|
|
19
|
+
|
|
20
|
+
/** Hard ceiling for the per-connection scan bound (`workspaces.limits.max_rows_per_namespace` can only lower it). */
|
|
21
|
+
export const MAX_SCAN_BOUND = 10000;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MESSAGE = "Required parameters are missing or in the wrong format";
|
|
24
|
+
|
|
25
|
+
/** Fails with a declared code; an empty or non-string message falls back to the provider's generic wording. */
|
|
26
|
+
export function fail(context, code, message) {
|
|
27
|
+
const text = typeof message === "string" ? clip(message, 3000).trim() : "";
|
|
28
|
+
context.fail({ code, message: text.length > 0 ? text : DEFAULT_MESSAGE });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const badRequest = (context, message = "Required parameters are missing or in the wrong format") =>
|
|
32
|
+
fail(context, "BAD_REQUEST", message);
|
|
33
|
+
|
|
34
|
+
export const unauthorized = (context, message = "Unauthorized") => fail(context, "UNAUTHORIZED", message);
|
|
35
|
+
|
|
36
|
+
export const forbidden = (context, message = "Forbidden") => fail(context, "FORBIDDEN", message);
|
|
37
|
+
|
|
38
|
+
export const notFound = (context, message = "Not found") => fail(context, "NOT_FOUND", message);
|
|
39
|
+
|
|
40
|
+
export const notImplemented = (context) =>
|
|
41
|
+
fail(context, "NOT_IMPLEMENTED", "The requested functionality is not supported by this integration");
|
|
42
|
+
|
|
43
|
+
export const tooLarge = (context) =>
|
|
44
|
+
fail(context, "PAYLOAD_TOO_LARGE", "Response exceeds 1 MB; lower limit or restrict fields");
|
|
45
|
+
|
|
46
|
+
export const scanBoundExceeded = (context, namespace, bound) =>
|
|
47
|
+
fail(context, "FAILED_PRECONDITION", `State exceeds the supported bound of ${bound} rows for ${namespace}`);
|
|
48
|
+
|
|
49
|
+
/** A field whose value is not what the schema allows: names the field and the bound, clipping any caller text. */
|
|
50
|
+
export const invalidField = (context, field, detail) => badRequest(context, `Invalid value for ${clip(field, 80)}: ${detail}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Firedrill events mirroring the provider's webhook vocabulary: object.created / object.updated / object.deleted
|
|
2
|
+
// with object types crm_contact, crm_company, crm_deal and messaging_message. A create emits both created and
|
|
3
|
+
// updated (the provider documents that "updated" fires for newly created records too).
|
|
4
|
+
|
|
5
|
+
function payload(connection, objectType, objectId) {
|
|
6
|
+
return {
|
|
7
|
+
workspace_id: connection.workspace_id,
|
|
8
|
+
connection_id: connection.id,
|
|
9
|
+
object_type: objectType,
|
|
10
|
+
object_id: objectId,
|
|
11
|
+
external_xref: connection.external_xref ?? null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function emitCreated(context, connection, objectType, objectId) {
|
|
16
|
+
context.events.emit("object.created", payload(connection, objectType, objectId));
|
|
17
|
+
context.events.emit("object.updated", { ...payload(connection, objectType, objectId), changed_fields: ["*"] });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emitUpdated(context, connection, objectType, objectId, changedFields) {
|
|
21
|
+
if (changedFields.length === 0) return;
|
|
22
|
+
context.events.emit("object.updated", { ...payload(connection, objectType, objectId), changed_fields: changedFields.slice(0, 64) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function emitDeleted(context, connection, objectType, objectId) {
|
|
26
|
+
context.events.emit("object.deleted", payload(connection, objectType, objectId));
|
|
27
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Identity and per-connection access rules, applied by every handler in this order:
|
|
2
|
+
// 1 workspace (actor attribute `workspaceId`, falling back to the first seeded workspace), 2 connection resolution
|
|
3
|
+
// (incl. the `connectionIds` scope), 3 connection health, 4 category, 5 permission.
|
|
4
|
+
import { badRequest, forbidden, notFound, notImplemented, unauthorized } from "./errors.mjs";
|
|
5
|
+
import { scanAll } from "./store.mjs";
|
|
6
|
+
import { clip, isHex24 } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
/** Resolves the caller's workspace row or fails UNAUTHORIZED; also refuses a codec refusal carried in the input. */
|
|
9
|
+
export function resolveWorkspace(input, context) {
|
|
10
|
+
if (typeof input.__request_error === "string") badRequest(context, input.__request_error);
|
|
11
|
+
const claimed = context.actor?.attributes?.workspaceId;
|
|
12
|
+
if (claimed !== undefined && claimed !== null) {
|
|
13
|
+
if (typeof claimed !== "string" || claimed.length === 0 || claimed.length > 64) unauthorized(context);
|
|
14
|
+
const row = context.state.get("workspaces", claimed);
|
|
15
|
+
if (row === null) unauthorized(context);
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
const rows = scanAll(context, "workspaces", 100);
|
|
19
|
+
if (rows.length === 0) unauthorized(context);
|
|
20
|
+
return rows[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The connection ids a connection-scoped key may see, or null for a workspace-wide key. */
|
|
24
|
+
export function connectionScope(context) {
|
|
25
|
+
const scope = context.actor?.attributes?.connectionIds;
|
|
26
|
+
if (!Array.isArray(scope)) return null;
|
|
27
|
+
const set = new Set();
|
|
28
|
+
for (const id of scope.slice(0, 256)) if (typeof id === "string") set.add(id);
|
|
29
|
+
return set;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const inScope = (scope, connectionId) => scope === null || scope.has(connectionId);
|
|
33
|
+
|
|
34
|
+
/** Loads a connection of the workspace within the caller's scope, or fails NOT_FOUND (a scoped key learns nothing). */
|
|
35
|
+
export function loadConnection(context, workspace, connectionId) {
|
|
36
|
+
if (!isHex24(connectionId)) badRequest(context, `Invalid connection_id: ${clip(connectionId, 60)}`);
|
|
37
|
+
const connection = context.state.get("connections", connectionId);
|
|
38
|
+
if (connection === null || connection.workspace_id !== workspace.id || !inScope(connectionScope(context), connectionId)) {
|
|
39
|
+
notFound(context, "Connection not found");
|
|
40
|
+
}
|
|
41
|
+
return connection;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the connection for a data operation and applies health, category and permission checks.
|
|
46
|
+
* `category` is "crm" or "messaging"; `permission` is the exact Unified.to permission slug.
|
|
47
|
+
*/
|
|
48
|
+
export function requireConnection(context, workspace, connectionId, category, permission) {
|
|
49
|
+
const connection = loadConnection(context, workspace, connectionId);
|
|
50
|
+
if (connection.last_unhealthy_code !== null && connection.last_unhealthy_code !== undefined) {
|
|
51
|
+
unauthorized(context, "The connection is likely broken and requires recreation");
|
|
52
|
+
}
|
|
53
|
+
if (connection.is_paused === true) forbidden(context, "Connection is paused; monthly plan limit exceeded");
|
|
54
|
+
if (!connection.categories.includes(category)) notImplemented(context);
|
|
55
|
+
if (!connection.permissions.includes(permission)) {
|
|
56
|
+
forbidden(context, `The connection lacks the required permissions or scopes: ${permission}`);
|
|
57
|
+
}
|
|
58
|
+
return connection;
|
|
59
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// List parameters shared by every list operation: limit/offset paging, updated_gte, sort/order, query search,
|
|
2
|
+
// reference filters, `fields` projection and the encoded-page byte budget.
|
|
3
|
+
import { PAGE_BYTE_BUDGET, badRequest, tooLarge } from "./errors.mjs";
|
|
4
|
+
import { parseIsoUs } from "./time.mjs";
|
|
5
|
+
import { clip, compareText, containsFolded, fold, isHex24, jsonBytes } from "./util.mjs";
|
|
6
|
+
|
|
7
|
+
export const MAX_LIMIT = 100;
|
|
8
|
+
const SORTS = new Set(["name", "updated_at", "created_at"]);
|
|
9
|
+
const ORDERS = new Set(["asc", "desc"]);
|
|
10
|
+
const MAX_QUERY_LENGTH = 2000;
|
|
11
|
+
const MAX_FIELDS = 64;
|
|
12
|
+
|
|
13
|
+
const isInt = (value) => typeof value === "number" && Number.isInteger(value);
|
|
14
|
+
|
|
15
|
+
/** Parses the shared list parameters; every problem is BAD_REQUEST with the provider's wording. */
|
|
16
|
+
export function parseListParams(input, context) {
|
|
17
|
+
let limit = MAX_LIMIT;
|
|
18
|
+
if (input.limit !== undefined && input.limit !== null) {
|
|
19
|
+
if (!isInt(input.limit) || input.limit < 1) badRequest(context, "limit must be a positive integer");
|
|
20
|
+
limit = Math.min(input.limit, MAX_LIMIT);
|
|
21
|
+
}
|
|
22
|
+
let offset = 0;
|
|
23
|
+
if (input.offset !== undefined && input.offset !== null) {
|
|
24
|
+
if (!isInt(input.offset) || input.offset < 0 || input.offset > Number.MAX_SAFE_INTEGER) badRequest(context, "offset must be a non-negative integer");
|
|
25
|
+
offset = input.offset;
|
|
26
|
+
}
|
|
27
|
+
let updatedGteUs = null;
|
|
28
|
+
if (input.updated_gte !== undefined && input.updated_gte !== null) {
|
|
29
|
+
updatedGteUs = parseIsoUs(input.updated_gte);
|
|
30
|
+
if (updatedGteUs === null) badRequest(context, "updated_gte must be an ISO-8601 date or date-time");
|
|
31
|
+
}
|
|
32
|
+
let sort = null;
|
|
33
|
+
if (input.sort !== undefined && input.sort !== null) {
|
|
34
|
+
if (typeof input.sort !== "string" || !SORTS.has(input.sort)) badRequest(context, "sort must be one of name, updated_at, created_at");
|
|
35
|
+
sort = input.sort;
|
|
36
|
+
}
|
|
37
|
+
let order = "asc";
|
|
38
|
+
if (input.order !== undefined && input.order !== null) {
|
|
39
|
+
if (typeof input.order !== "string" || !ORDERS.has(input.order)) badRequest(context, "order must be asc or desc");
|
|
40
|
+
order = input.order;
|
|
41
|
+
}
|
|
42
|
+
let query = null;
|
|
43
|
+
if (input.query !== undefined && input.query !== null) {
|
|
44
|
+
if (typeof input.query !== "string" || input.query.length > MAX_QUERY_LENGTH) badRequest(context, `query must be a string of at most ${MAX_QUERY_LENGTH} characters`);
|
|
45
|
+
if (input.query.includes("�")) badRequest(context, "query contains malformed percent-encoding");
|
|
46
|
+
query = fold(input.query);
|
|
47
|
+
}
|
|
48
|
+
return { limit, offset, updatedGteUs, sort, order, query };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A reference filter (`company_id`, `channel_id`, ...) must be a well-formed id; an unknown id matches nothing. */
|
|
52
|
+
export function idFilter(input, context, name, extra = null) {
|
|
53
|
+
const value = input[name];
|
|
54
|
+
if (value === undefined || value === null) return null;
|
|
55
|
+
if (typeof value !== "string" || (!isHex24(value) && !(extra !== null && extra.has(value)))) {
|
|
56
|
+
badRequest(context, `${name} must be a valid id`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A plain string filter (`user_id`, `external_xref`). */
|
|
62
|
+
export function textFilter(input, context, name, max = 256) {
|
|
63
|
+
const value = input[name];
|
|
64
|
+
if (value === undefined || value === null) return null;
|
|
65
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max) badRequest(context, `${name} must be a string of at most ${max} characters`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function enumFilter(input, context, name, allowed) {
|
|
70
|
+
const value = input[name];
|
|
71
|
+
if (value === undefined || value === null) return null;
|
|
72
|
+
if (typeof value !== "string" || !allowed.includes(value)) badRequest(context, `${name} must be one of ${allowed.join(", ")}`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when any of the row's searchable strings contains the folded query. */
|
|
77
|
+
export function matchesQuery(row, query, fields) {
|
|
78
|
+
if (query === null) return true;
|
|
79
|
+
for (const field of fields) {
|
|
80
|
+
const value = row[field];
|
|
81
|
+
if (typeof value === "string") {
|
|
82
|
+
if (containsFolded(value, query)) return true;
|
|
83
|
+
} else if (Array.isArray(value)) {
|
|
84
|
+
for (const entry of value) {
|
|
85
|
+
const text = typeof entry === "string" ? entry : entry?.email;
|
|
86
|
+
if (typeof text === "string" && containsFolded(text, query)) return true;
|
|
87
|
+
}
|
|
88
|
+
} else if (value !== null && typeof value === "object") {
|
|
89
|
+
for (const inner of Object.values(value)) if (containsFolded(inner, query)) return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const updatedSince = (row, updatedGteUs) => updatedGteUs === null || (parseIsoUs(row.updated_at) ?? 0) >= updatedGteUs;
|
|
96
|
+
|
|
97
|
+
/** Sorts in place by the requested field (ties by id); no sort keeps creation (row-id) order. */
|
|
98
|
+
export function sortRows(rows, sort, order, nameField = "name") {
|
|
99
|
+
if (sort === null) {
|
|
100
|
+
if (order === "desc") rows.reverse();
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
const field = sort === "name" ? nameField : sort;
|
|
104
|
+
const sign = order === "desc" ? -1 : 1;
|
|
105
|
+
rows.sort((a, b) => {
|
|
106
|
+
const left = a[field] ?? "";
|
|
107
|
+
const right = b[field] ?? "";
|
|
108
|
+
const cmp = compareText(String(left), String(right));
|
|
109
|
+
return cmp !== 0 ? sign * cmp : compareText(a.id, b.id);
|
|
110
|
+
});
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parses `fields` (comma-separated or an array) into a Set, or null for the default projection. */
|
|
115
|
+
export function parseFields(input, context) {
|
|
116
|
+
const raw = input.fields;
|
|
117
|
+
if (raw === undefined || raw === null) return null;
|
|
118
|
+
const parts = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : null;
|
|
119
|
+
if (parts === null) badRequest(context, "fields must be a comma-separated list of field names");
|
|
120
|
+
const set = new Set();
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
if (typeof part !== "string") badRequest(context, "fields must be a comma-separated list of field names");
|
|
123
|
+
for (const name of part.split(",")) {
|
|
124
|
+
const trimmed = name.trim();
|
|
125
|
+
if (trimmed.length > 0 && trimmed.length <= 200) set.add(trimmed);
|
|
126
|
+
if (set.size > MAX_FIELDS) badRequest(context, `fields lists more than ${MAX_FIELDS} names`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
set.add("id");
|
|
130
|
+
return set;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Applies the projection: default is every field except `raw`; `id` is always present; unknown names are ignored. */
|
|
134
|
+
export function project(row, fields, hidden = []) {
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const key of Object.keys(row)) {
|
|
137
|
+
if (key === "connection_id" || hidden.includes(key)) continue;
|
|
138
|
+
if (fields === null ? key !== "raw" : fields.has(key)) out[key] = row[key];
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Slices the page and refuses one whose encoding would exceed the byte budget (offset paging cannot shorten it). */
|
|
144
|
+
export function finishPage(context, rows, params, fields, hidden = []) {
|
|
145
|
+
const page = rows.slice(params.offset, params.offset + params.limit).map((row) => project(row, fields, hidden));
|
|
146
|
+
let bytes = 2;
|
|
147
|
+
for (const item of page) {
|
|
148
|
+
bytes += jsonBytes(item) + 1;
|
|
149
|
+
if (bytes > PAGE_BYTE_BUDGET) tooLarge(context);
|
|
150
|
+
}
|
|
151
|
+
return page;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const describe = (value) => clip(typeof value === "string" ? value : JSON.stringify(value), 80);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Composite value validators (emails, telephones, address, metadata, members, references, `raw`). Each returns the
|
|
2
|
+
// normalised value or fails BAD_REQUEST naming the field; caller keys are copied one by one, never spread.
|
|
3
|
+
import { badRequest, invalidField } from "./errors.mjs";
|
|
4
|
+
import { clip, isHex24, isPlainObject, jsonBytes } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
const BANNED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
7
|
+
const EMAIL_TYPES = ["WORK", "HOME", "OTHER"];
|
|
8
|
+
const TELEPHONE_TYPES = ["WORK", "HOME", "OTHER", "FAX", "MOBILE"];
|
|
9
|
+
export const METADATA_FORMATS = ["TEXT", "NUMBER", "DATE", "BOOLEAN", "TEXTAREA", "SINGLE_SELECT", "MULTIPLE_SELECT", "URL", "EMAIL", "PHONE", "CURRENCY", "PERCENT", "USER", "OBJECT", "MEASUREMENT"];
|
|
10
|
+
const ADDRESS_FIELDS = ["address1", "address2", "city", "region", "region_code", "postal_code", "country", "country_code"];
|
|
11
|
+
const MEMBER_FIELDS = ["user_id", "email", "name", "image_url"];
|
|
12
|
+
const RAW_MAX_DEPTH = 8;
|
|
13
|
+
const RAW_MAX_KEYS = 64;
|
|
14
|
+
const RAW_MAX_BYTES = 16384;
|
|
15
|
+
|
|
16
|
+
const nullableString = (context, field, value, max) => {
|
|
17
|
+
if (value === undefined || value === null) return null;
|
|
18
|
+
if (typeof value !== "string" || value.length > max) invalidField(context, field, `must be a string of at most ${max} characters`);
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function objectWith(context, field, value, names, max) {
|
|
23
|
+
if (!isPlainObject(value)) invalidField(context, field, "must be an object");
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const key of Object.keys(value)) {
|
|
26
|
+
if (!names.includes(key)) badRequest(context, `Unknown field "${clip(`${field}.${key}`, 80)}"`);
|
|
27
|
+
}
|
|
28
|
+
for (const name of names) out[name] = nullableString(context, `${field}.${name}`, value[name], max);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function arrayOf(context, field, value, maxItems, each) {
|
|
33
|
+
if (value === undefined || value === null) return [];
|
|
34
|
+
if (!Array.isArray(value)) invalidField(context, field, "must be an array");
|
|
35
|
+
if (value.length > maxItems) invalidField(context, field, `must have at most ${maxItems} entries`);
|
|
36
|
+
return value.map((entry, index) => each(`${field}[${index}]`, entry));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const emails = (context, field, value) =>
|
|
40
|
+
arrayOf(context, field, value, 32, (where, entry) => {
|
|
41
|
+
const out = objectWith(context, where, entry, ["email", "type"], 320);
|
|
42
|
+
if (typeof out.email !== "string" || !out.email.includes("@") || out.email.length < 3) invalidField(context, `${where}.email`, "must be an e-mail address");
|
|
43
|
+
if (out.type !== null && !EMAIL_TYPES.includes(out.type)) invalidField(context, `${where}.type`, `must be one of ${EMAIL_TYPES.join(", ")}`);
|
|
44
|
+
return out;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const telephones = (context, field, value) =>
|
|
48
|
+
arrayOf(context, field, value, 32, (where, entry) => {
|
|
49
|
+
const out = objectWith(context, where, entry, ["telephone", "type"], 64);
|
|
50
|
+
if (typeof out.telephone !== "string" || out.telephone.length === 0) invalidField(context, `${where}.telephone`, "is required");
|
|
51
|
+
if (out.type !== null && !TELEPHONE_TYPES.includes(out.type)) invalidField(context, `${where}.type`, `must be one of ${TELEPHONE_TYPES.join(", ")}`);
|
|
52
|
+
return out;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const address = (context, field, value) => (value === undefined || value === null ? null : objectWith(context, field, value, ADDRESS_FIELDS, 256));
|
|
56
|
+
|
|
57
|
+
const member = (context, field, value) => {
|
|
58
|
+
const out = objectWith(context, field, value, MEMBER_FIELDS, 320);
|
|
59
|
+
if (out.email !== null && !out.email.includes("@")) invalidField(context, `${field}.email`, "must be an e-mail address");
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const members = (context, field, value) => arrayOf(context, field, value, 100, (where, entry) => member(context, where, entry));
|
|
64
|
+
|
|
65
|
+
const metadata = (context, field, value) =>
|
|
66
|
+
arrayOf(context, field, value, 100, (where, entry) => {
|
|
67
|
+
if (!isPlainObject(entry)) invalidField(context, where, "must be an object");
|
|
68
|
+
for (const key of Object.keys(entry)) {
|
|
69
|
+
if (!["id", "slug", "namespace", "format", "value", "extra_data"].includes(key)) badRequest(context, `Unknown field "${clip(`${where}.${key}`, 80)}"`);
|
|
70
|
+
}
|
|
71
|
+
const out = {
|
|
72
|
+
id: nullableString(context, `${where}.id`, entry.id, 256),
|
|
73
|
+
slug: nullableString(context, `${where}.slug`, entry.slug, 256),
|
|
74
|
+
namespace: nullableString(context, `${where}.namespace`, entry.namespace, 256),
|
|
75
|
+
format: nullableString(context, `${where}.format`, entry.format, 32),
|
|
76
|
+
value: entry.value === undefined ? null : entry.value,
|
|
77
|
+
extra_data: entry.extra_data === undefined ? null : entry.extra_data,
|
|
78
|
+
};
|
|
79
|
+
if (out.id === null && out.slug === null) invalidField(context, where, "needs an id or a slug");
|
|
80
|
+
if (out.format !== null && !METADATA_FORMATS.includes(out.format)) invalidField(context, `${where}.format`, `must be one of ${METADATA_FORMATS.join(", ")}`);
|
|
81
|
+
const kind = typeof out.value;
|
|
82
|
+
if (out.value !== null && kind !== "string" && kind !== "number" && kind !== "boolean") invalidField(context, `${where}.value`, "must be a string, number, boolean or null");
|
|
83
|
+
if (kind === "string" && out.value.length > 4000) invalidField(context, `${where}.value`, "must be at most 4000 characters");
|
|
84
|
+
if (kind === "number" && !Number.isFinite(out.value)) invalidField(context, `${where}.value`, "must be finite");
|
|
85
|
+
out.extra_data = out.extra_data === null ? null : raw(context, `${where}.extra_data`, out.extra_data);
|
|
86
|
+
return out;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Copies a caller object key by key into fresh objects, bounding depth, keys per level and encoded size. */
|
|
90
|
+
function raw(context, field, value) {
|
|
91
|
+
if (value === undefined || value === null) return {};
|
|
92
|
+
if (!isPlainObject(value)) invalidField(context, field, "must be an object");
|
|
93
|
+
const root = {};
|
|
94
|
+
const stack = [[value, root, 1]];
|
|
95
|
+
while (stack.length > 0) {
|
|
96
|
+
const [source, target, depth] = stack.pop();
|
|
97
|
+
if (depth > RAW_MAX_DEPTH) invalidField(context, field, `must not nest deeper than ${RAW_MAX_DEPTH} levels`);
|
|
98
|
+
const keys = Object.keys(source);
|
|
99
|
+
if (keys.length > RAW_MAX_KEYS) invalidField(context, field, `must have at most ${RAW_MAX_KEYS} keys per level`);
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
if (BANNED_KEYS.has(key) || key.length > 256) badRequest(context, `Invalid key in ${clip(field, 80)}: "${clip(key, 60)}"`);
|
|
102
|
+
const child = source[key];
|
|
103
|
+
if (isPlainObject(child)) {
|
|
104
|
+
const copy = {};
|
|
105
|
+
target[key] = copy;
|
|
106
|
+
stack.push([child, copy, depth + 1]);
|
|
107
|
+
} else if (Array.isArray(child)) {
|
|
108
|
+
if (child.length > RAW_MAX_KEYS) invalidField(context, field, `arrays must have at most ${RAW_MAX_KEYS} entries`);
|
|
109
|
+
const copy = [];
|
|
110
|
+
target[key] = copy;
|
|
111
|
+
for (const entry of child) {
|
|
112
|
+
if (isPlainObject(entry)) {
|
|
113
|
+
const inner = {};
|
|
114
|
+
copy.push(inner);
|
|
115
|
+
stack.push([entry, inner, depth + 1]);
|
|
116
|
+
} else if (Array.isArray(entry)) invalidField(context, field, "nested arrays are not supported");
|
|
117
|
+
else copy.push(entry === undefined ? null : entry);
|
|
118
|
+
}
|
|
119
|
+
} else if (typeof child === "number" && !Number.isFinite(child)) invalidField(context, field, "numbers must be finite");
|
|
120
|
+
else target[key] = child === undefined ? null : child;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (jsonBytes(root) > RAW_MAX_BYTES) invalidField(context, field, `must encode to at most ${RAW_MAX_BYTES} bytes`);
|
|
124
|
+
return root;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const reactions = (context, field, value) =>
|
|
128
|
+
arrayOf(context, field, value, 200, (where, entry) => {
|
|
129
|
+
if (!isPlainObject(entry)) invalidField(context, where, "must be an object");
|
|
130
|
+
for (const key of Object.keys(entry)) if (key !== "reaction" && key !== "member") badRequest(context, `Unknown field "${clip(`${where}.${key}`, 80)}"`);
|
|
131
|
+
const reaction = nullableString(context, `${where}.reaction`, entry.reaction, 64);
|
|
132
|
+
if (reaction === null) invalidField(context, `${where}.reaction`, "is required");
|
|
133
|
+
return { reaction, member: member(context, `${where}.member`, entry.member ?? {}) };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const attachments = (context, field, value) =>
|
|
137
|
+
arrayOf(context, field, value, 32, (where, entry) => {
|
|
138
|
+
if (!isPlainObject(entry)) invalidField(context, where, "must be an object");
|
|
139
|
+
const size = entry.size;
|
|
140
|
+
const rest = {};
|
|
141
|
+
for (const key of Object.keys(entry)) if (key !== "size") rest[key] = entry[key];
|
|
142
|
+
const out = objectWith(context, where, rest, ["filename", "content_type", "download_url", "content_identifier", "message_id"], 2048);
|
|
143
|
+
if (size !== undefined && size !== null && (typeof size !== "number" || !Number.isFinite(size) || size < 0)) invalidField(context, `${where}.size`, "must be a non-negative number");
|
|
144
|
+
out.size = size === undefined ? null : size;
|
|
145
|
+
return out;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const buttons = (context, field, value) =>
|
|
149
|
+
arrayOf(context, field, value, 16, (where, entry) => {
|
|
150
|
+
const out = objectWith(context, where, entry, ["id", "text", "icon"], 256);
|
|
151
|
+
if (out.id === null) invalidField(context, `${where}.id`, "is required");
|
|
152
|
+
return out;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** `channels`, `pipelines`, `stages`: arrays of `{ id, name?, type? }` references whose ids must be well-formed. */
|
|
156
|
+
const refs = (max, names) => (context, field, value) =>
|
|
157
|
+
arrayOf(context, field, value, max, (where, entry) => {
|
|
158
|
+
const out = objectWith(context, where, entry, names, 256);
|
|
159
|
+
if (!isHex24(out.id)) invalidField(context, `${where}.id`, "must be a valid id");
|
|
160
|
+
return out;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const SHAPES = new Map([
|
|
164
|
+
["emails", emails],
|
|
165
|
+
["telephones", telephones],
|
|
166
|
+
["address", address],
|
|
167
|
+
["member", (context, field, value) => (value === undefined || value === null ? null : member(context, field, value))],
|
|
168
|
+
["members", members],
|
|
169
|
+
["metadata", metadata],
|
|
170
|
+
["raw", raw],
|
|
171
|
+
["reactions", reactions],
|
|
172
|
+
["attachments", attachments],
|
|
173
|
+
["buttons", buttons],
|
|
174
|
+
["channelRefs", refs(8, ["id", "name"])],
|
|
175
|
+
["pipelineRefs", refs(1, ["id", "name", "type"])],
|
|
176
|
+
]);
|