@fargowealth/fargo-ai-cli 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createCredentialStore } from "./credential-store.mjs";
5
+
6
+ export function extractYidaProcessId(result) {
7
+ if (result?.success !== true) throw new Error("YIDA_PROCESS_RESULT_UNVERIFIED");
8
+ const candidates = [result.processInstanceId, typeof result.content === "object" ? result.content?.processInstanceId : result.content,
9
+ typeof result.result === "object" ? result.result?.processInstanceId : result.result].filter(value => value !== undefined && value !== null);
10
+ if (!candidates.length || candidates.some(value => typeof value !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(value)) || new Set(candidates).size !== 1) {
11
+ throw new Error("YIDA_PROCESS_RESULT_UNVERIFIED: reconcile before any new submission");
12
+ }
13
+ return candidates[0];
14
+ }
15
+
16
+ async function regular(path, directory = false) {
17
+ const value = await lstat(path);
18
+ if (value.isSymbolicLink() || !(directory ? value.isDirectory() : value.isFile()) ||
19
+ (typeof process.getuid === "function" && value.uid !== process.getuid())) throw new Error("YIDA_OPERATION_STORE_INVALID");
20
+ }
21
+ async function save(path, record) {
22
+ const temporary = `${path}.${randomUUID()}.tmp`;
23
+ let handle;
24
+ try {
25
+ handle = await open(temporary, "wx", 0o600);
26
+ await handle.writeFile(`${JSON.stringify(record)}\n`);
27
+ await handle.sync();
28
+ await handle.close();
29
+ handle = undefined;
30
+ await rename(temporary, path);
31
+ } finally { await handle?.close(); await rm(temporary, { force: true }); }
32
+ }
33
+
34
+ // This is a local replay guard, not an upstream idempotency guarantee. Unknown
35
+ // results require read-back; a second invocation must never create another instance.
36
+ export async function runRecordedYidaMutation({ directory, requestId, payloadHash, send, allowSafeRetry = false }) {
37
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(requestId) || !/^sha256:[a-f0-9]{64}$/.test(payloadHash)) throw new Error("YIDA_OPERATION_INVALID");
38
+ await mkdir(directory, { recursive: true, mode: 0o700 });
39
+ await regular(directory, true);
40
+ const file = join(directory, `${requestId}.json`);
41
+ const lock = createCredentialStore({ env: { FARGO_REFRESH_LOCK_PATH: join(directory, `${requestId}.lock`) } });
42
+ return lock.withRefreshLock(async () => {
43
+ let previous;
44
+ try { await regular(file); previous = JSON.parse(await readFile(file, "utf8")); }
45
+ catch (error) { if (error.code !== "ENOENT") throw error; }
46
+ if (previous) {
47
+ if (previous.payloadHash !== payloadHash) throw new Error("YIDA_OPERATION_CONFLICT: request ID already belongs to another payload");
48
+ if (previous.status === "accepted") return { ...previous.receipt, replayedFromReceipt: true };
49
+ if (!allowSafeRetry || previous.status !== "not_applied") throw new Error("YIDA_OPERATION_RESULT_UNKNOWN: do not resend; reconcile the recorded request first");
50
+ }
51
+ const record = { requestId, payloadHash, status: "sending", startedAt: new Date().toISOString() };
52
+ await save(file, record);
53
+ try {
54
+ const receipt = await send();
55
+ await save(file, { ...record, status: "accepted", receipt, completedAt: new Date().toISOString() });
56
+ return receipt;
57
+ } catch (error) {
58
+ // Opt-in only: a later explicit invocation can retry a proven pre-dispatch
59
+ // failure or authorization rejection. Timeouts/ambiguous responses stay blocked.
60
+ await save(file, { ...record, status: allowSafeRetry && error.yidaMutationNotApplied === true ? "not_applied" : "result_unknown", upstreamCode: error.upstreamCode ?? null });
61
+ throw error;
62
+ }
63
+ });
64
+ }
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const hash = value => `sha256:${createHash("sha256").update(typeof value === "string" ? value : JSON.stringify(value)).digest("hex")}`;
4
+ const refs = text => [...new Set([...String(text).matchAll(/#\{([^}]+)\}/g)].map(match => match[1]))];
5
+ const object = value => { try { return typeof value === "string" ? JSON.parse(value) : value; } catch { return null; } };
6
+
7
+ // Describe business configuration; never execute page JavaScript or formulas.
8
+ export function modelYidaRules(content, appType) {
9
+ const rules = [], connectors = [], scripts = [], datasetQueries = [], pageBindings = [];
10
+ const seen = new Set();
11
+ let truncated = false, visited = 0;
12
+ function add(rule) {
13
+ const key = JSON.stringify([rule.fieldId, rule.kind, rule.expression]);
14
+ if (seen.has(key)) return;
15
+ seen.add(key);
16
+ if (rules.length >= 5000 || rule.expression?.length > 16000) { truncated = true; return; }
17
+ rules.push({ ...rule, inputs: refs(rule.expression), verification: "configuration_read_runtime_not_verified" });
18
+ }
19
+ function formula(props, value, kind, parentFieldId) {
20
+ if (!value || (typeof value === "string" && !value.trim())) return;
21
+ if (typeof value === "string") {
22
+ const relate = value.match(/^@\{\{(FORM-[A-Za-z0-9_-]+)\/([A-Za-z0-9_]+)\}\}$/);
23
+ if (relate) add({ fieldId: props.fieldId, parentFieldId, kind, expression: value,
24
+ source: { kind: "form_lookup", appType, formUuid: relate[1], fieldId: relate[2] }, lookupMode: "relate" });
25
+ else add({ fieldId: props.fieldId, parentFieldId, kind, expression: value });
26
+ return;
27
+ }
28
+ const config = value.data;
29
+ if (!config || typeof config !== "object" || Array.isArray(config)) return;
30
+ const events = Object.entries(value.event ?? {}).flatMap(([event, items]) => Array.isArray(items) ? items.map(item => ({ event, item })) : []);
31
+ const expressions = events.filter(({ item }) => typeof item.params?.value === "string" && item.params.target === props.fieldId);
32
+ for (const { item } of expressions) {
33
+ add({ fieldId: props.fieldId, parentFieldId, kind, expression: item.params.value,
34
+ source: { kind: "form_lookup", appType: config.appType ?? appType, formUuid: config.formId, fieldId: config.otherLinkageFieldId,
35
+ deduplicate: config.deduplication === true, conditions: config.conditions ?? null },
36
+ triggers: events.filter(x => x.item.params?.target === props.fieldId).map(x => ({ fieldId: x.item.fieldId, event: x.event })), lookupMode: "linkage" });
37
+ }
38
+ }
39
+ function visit(node, parentFieldId = null, depth = 0) {
40
+ if (!node || typeof node !== "object") return;
41
+ if (++visited > 10000 || depth > 100) { truncated = true; return; }
42
+ const props = node.props ?? {};
43
+ for (const key of ["formUuid", "formId", "targetFormUuid", "appType", "viewUuid"]) {
44
+ if (typeof props[key] === "string" && /^(?:FORM-|APP_|VIEW-)[A-Za-z0-9_-]+$/.test(props[key])) pageBindings.push({ componentId: node.id, componentName: node.componentName, property: key, value: props[key] });
45
+ }
46
+ for (const [dataSetKey, dataset] of Object.entries(props.dataSetModelMap ?? {})) {
47
+ if (!dataset || typeof dataset !== "object") continue;
48
+ const query = dataset.dataViewQueryModel ?? {};
49
+ datasetQueries.push({ componentId: node.id, componentName: node.componentName, cid: props.cid, dataSetKey,
50
+ cubeCodes: dataset.cubeCodes, limit: dataset.limit, configurationHash: hash(query),
51
+ fields: (query.fieldDefinitionList ?? []).map(field => ({ fieldCode: field.fieldCode, alias: field.alias, cubeCode: field.cubeCode,
52
+ aggregateType: field.aggregateType, timeGranularityType: field.timeGranularityType, dataType: field.dataType, isDim: field.isDim })),
53
+ selectedAliases: query.fieldList, filters: query.filterList, orderBy: query.orderByList, groupBy: query.groupByList,
54
+ verification: "configuration_read_runtime_not_verified" });
55
+ }
56
+ let parent = parentFieldId;
57
+ if (props.fieldId && /Field$/.test(node.componentName ?? "")) {
58
+ formula(props, props.formula, "calculation", parentFieldId);
59
+ formula(props, props.complexValue?.formula, props.complexValue?.complexType === "linkage" ? "linked_value" : "calculation", parentFieldId);
60
+ formula(props, props.defaultDataSource?.formula, "options", parentFieldId);
61
+ if (props.behavior && typeof props.behavior === "object") {
62
+ rules.push({ fieldId: props.fieldId, parentFieldId, kind: "visibility", binding: {
63
+ type: props.behavior.type, variable: props.behavior.variable, initialValue: props.behavior.value
64
+ }, verification: "configuration_read_runtime_not_verified" });
65
+ }
66
+ parent = props.fieldId;
67
+ }
68
+ for (const source of node.dataSource?.online ?? []) {
69
+ if (source.dpType !== "YIDACONNECTOR") continue;
70
+ const info = object(source.options?.params?.serviceInfo)?.connectorInfo;
71
+ if (!info) continue;
72
+ connectors.push({ name: source.name, kind: "configured_connector", connectorId: info.connectorId, actionId: info.actionId,
73
+ connection: info.connection, type: info.type, inputTemplateHash: hash(source.options?.params?.inputs ?? ""),
74
+ verification: "configuration_read_adapter_required" });
75
+ }
76
+ for (const child of Array.isArray(node.children) ? node.children : []) visit(child, parent, depth + 1);
77
+ }
78
+ for (const page of content.pages ?? []) for (const node of page.componentsTree ?? []) visit(node);
79
+ const code = content.actions?.module?.compiled ?? content.actions?.module?.source;
80
+ if (typeof code === "string" && code) scripts.push({ sha256: hash(code),
81
+ actionNames: [...new Set([...code.matchAll(/exports\.([A-Za-z_$][\w$]*)\s*=/g)].map(x => x[1]))],
82
+ referencedFieldIds: [...new Set(code.match(/(?:[A-Za-z]+Field)_[A-Za-z0-9_]+/g) ?? [])],
83
+ verification: "source_detected_manual_adapter_required", executableSourceIncluded: false });
84
+ return JSON.parse(JSON.stringify({ rules, connectors, scripts, datasetQueries, pageBindings, ruleCount: rules.length, truncated, formulasExecuted: false }));
85
+ }
86
+
87
+ export function compileYidaLookup(rule, values, appType) {
88
+ if (!rule?.source || rule.source.appType !== appType || !["options", "linked_value"].includes(rule.kind)) throw new Error("YIDA_RULE_UNSUPPORTED: no same-app configured lookup");
89
+ let expression;
90
+ if (rule.lookupMode === "relate") {
91
+ expression = `FETCHDISTINCTDATA(${JSON.stringify(rule.source.formUuid)},${JSON.stringify(rule.source.fieldId)},QUERYLIKECI(${JSON.stringify(rule.source.fieldId)},""),"1","100")`;
92
+ } else {
93
+ expression = rule.expression.replace(/#\{([^}]+)\}/g, (_match, fieldId) => {
94
+ if (!Object.hasOwn(values, fieldId)) throw new Error(`YIDA_RULE_INPUT_REQUIRED: ${fieldId}`);
95
+ const value = values[fieldId];
96
+ if (value !== null && !["string", "number", "boolean"].includes(typeof value) || typeof value === "number" && !Number.isFinite(value) || typeof value === "string" && value.length > 2000) throw new Error("YIDA_RULE_INPUT_INVALID");
97
+ return JSON.stringify(value);
98
+ });
99
+ }
100
+ return `{${appType}}@${expression}`;
101
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { yidaRead, yidaLookupExpression, yidaApplicantConnector, yidaStatus } from "./yida-auth.mjs";
3
+ import { describeYidaForm, modelYidaSchema, validateYidaForm } from "./yida-schema.mjs";
4
+ import { compileYidaLookup } from "./yida-rules.mjs";
5
+
6
+ export async function resolveYidaField(store, appType, formUuid, fieldId, values = {}, options = {}) {
7
+ validateYidaForm(appType, formUuid);
8
+ if (typeof fieldId !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,199}$/.test(fieldId) || !values || typeof values !== "object" || Array.isArray(values) || Object.keys(values).length > 100) throw new Error("YIDA_RULE_INPUT_INVALID");
9
+ const payload = await yidaRead(store, `/alibaba/web/${appType}/_view/query/formdesign/getFormSchema.json?${new URLSearchParams({ formUuid, schemaVersion: "V5" })}`, options);
10
+ const model = modelYidaSchema(payload, { appType, formUuid });
11
+ if (model.truncated || options.schemaHash && options.schemaHash !== model.schemaHash) throw new Error("YIDA_SCHEMA_CHANGED");
12
+ const kind = options.kind ?? "options";
13
+ const rules = model.runtime.rules.filter(rule => rule.fieldId === fieldId && rule.kind === kind);
14
+ if (rules.length !== 1) throw new Error("YIDA_RULE_UNSUPPORTED: select one configured options or linked-value rule");
15
+ const expression = compileYidaLookup(rules[0], values, appType);
16
+ const result = await yidaLookupExpression(store, appType, formUuid, expression, options);
17
+ const data = result.content ?? result.result;
18
+ if (!Array.isArray(data) || data.length > 1000) throw new Error("YIDA_LOOKUP_UNEXPECTED_RESPONSE");
19
+ return { fieldId, kind, values: data, evidence: { schemaHash: model.schemaHash, sourceFormUuid: rules[0].source.formUuid,
20
+ queryHash: `sha256:${createHash("sha256").update(expression).digest("hex")}`, verification: "api_readback", checkedAt: new Date().toISOString(),
21
+ // Related option requests have an explicit 100-item bound; no claim of completeness.
22
+ possiblyTruncated: rules[0].lookupMode === "relate" && data.length >= 100 } };
23
+ }
24
+
25
+ export async function resolveYidaApplicant(store, appType, formUuid, departmentId, options = {}) {
26
+ const status = await yidaStatus(store, options);
27
+ const candidates = status.profiles.filter(profile => !options.profile || profile.profile === options.profile);
28
+ if (candidates.length !== 1) throw new Error("YIDA_PROFILE_REQUIRED");
29
+ const principal = candidates[0];
30
+ const local = { ...options, profile: principal.profile };
31
+ const model = await describeYidaForm(store, appType, formUuid, local);
32
+ if (options.schemaHash && model.schemaHash !== options.schemaHash) throw new Error("YIDA_SCHEMA_CHANGED");
33
+ const hrConnector = model.runtime.connectors.find(source => source.name === "connector2");
34
+ const hrResponse = await yidaApplicantConnector(store, hrConnector, "self_hr", undefined, local);
35
+ return resolveYidaApplicantFromContext(store, model, principal, hrResponse, departmentId, local);
36
+ }
37
+
38
+ // Internal adapter entry: reuse a schema already checked against the live hash
39
+ // and this operation's own HR response. Never accept these values over MCP.
40
+ export async function resolveYidaApplicantFromContext(store, model, principal, hrResponse, departmentId, options = {}) {
41
+ const local = { ...options, profile: principal.profile };
42
+ if (options.schemaHash && model.schemaHash !== options.schemaHash) throw new Error("YIDA_SCHEMA_CHANGED");
43
+ const departmentConnector = model.runtime.connectors.find(source => source.name === "connector1");
44
+ const hr = hrResponse.content?.serviceReturnValue ?? hrResponse.content;
45
+ if (hr?.errcode !== 0 || hr.success !== true || hr.result?.length !== 1 || hr.result[0].userid !== principal.userId) throw new Error("YIDA_APPLICANT_LOOKUP_FAILED");
46
+ const fields = hr.result[0].field_data_list;
47
+ const field = (code, key = "value") => fields?.find(item => item.field_code === code)?.field_value_list?.[0]?.[key];
48
+ const ids = String(field("sys00-deptIds") ?? "").split("|");
49
+ const names = String(field("sys00-dept", "label") ?? "").split(",");
50
+ if (ids.length !== names.length || ids.some(id => !/^\d{1,20}$/.test(id))) throw new Error("YIDA_APPLICANT_DEPARTMENTS_INVALID");
51
+ const selectedId = departmentId ?? field("sys00-mainDeptId");
52
+ const index = ids.indexOf(selectedId);
53
+ if (index < 0) throw new Error("YIDA_APPLICANT_DEPARTMENT_FORBIDDEN");
54
+ const response = await yidaApplicantConnector(store, departmentConnector, "department", selectedId, local);
55
+ const dept = response.content?.serviceReturnValue ?? response.content;
56
+ const managers = dept?.result?.dept_manager_userid_list;
57
+ if (dept?.errcode !== 0 || !Array.isArray(managers) || !managers.length || managers.some(id => typeof id !== "string" || !/^[A-Za-z0-9_.@-]{1,200}$/.test(id))) throw new Error("YIDA_APPLICANT_MANAGER_UNRESOLVED");
58
+ return { userId: principal.userId, departmentId: selectedId, departmentName: names[index],
59
+ departments: ids.map((id, i) => ({ value: id, label: names[i] })),
60
+ // The page's fetchDeptManager selects the first returned manager.
61
+ managerIds: managers.slice(0, 1), workPlace: field("sys00-workPlace") ?? null, positionLevel: field("sys01-positionLevel", "label") ?? null,
62
+ evidence: { schemaHash: model.schemaHash, verification: "api_readback", checkedAt: new Date().toISOString() } };
63
+ }
@@ -0,0 +1,111 @@
1
+ // Schema conventions adapted from OpenYida (MIT). See THIRD_PARTY_NOTICES.md.
2
+ import { createHash } from "node:crypto";
3
+ import { yidaRead } from "./yida-auth.mjs";
4
+ import { modelYidaRules } from "./yida-rules.mjs";
5
+
6
+ export function validateYidaForm(appType, formUuid) {
7
+ if (!/^APP_[A-Za-z0-9_-]{1,200}$/.test(appType ?? "") || !/^FORM-[A-Za-z0-9_-]{1,200}$/.test(formUuid ?? "")) {
8
+ throw new Error("YIDA_FORM_INVALID: appType and formUuid must be exact YiDA identifiers");
9
+ }
10
+ }
11
+
12
+ function object(value) {
13
+ if (typeof value === "string") {
14
+ try { return JSON.parse(value); } catch { return null; }
15
+ }
16
+ return value && typeof value === "object" ? value : null;
17
+ }
18
+
19
+ function label(value) {
20
+ const parsed = object(value);
21
+ return String(parsed?.zh_CN ?? parsed?.zh_HK ?? parsed?.en_US ?? parsed?.text ?? (typeof value === "string" ? value : "")).slice(0, 1000);
22
+ }
23
+
24
+ function formReferences(value) {
25
+ return [...new Set((JSON.stringify(value) ?? "").match(/FORM-[A-Za-z0-9_-]+/g) ?? [])].sort();
26
+ }
27
+
28
+ function hasFormula(value) {
29
+ if (value === undefined || value === null || value === "") return false;
30
+ if (typeof value === "string") return Boolean(value.trim());
31
+ if (Array.isArray(value)) return value.length > 0;
32
+ if (typeof value !== "object") return Boolean(value);
33
+ if (["expression", "source", "value", "formula"].some(key => value[key] !== undefined && value[key] !== null && value[key] !== "")) return true;
34
+ if (Array.isArray(value.data) && value.data.length) return true;
35
+ return Boolean(value.event && typeof value.event === "object" && Object.values(value.event).some(events => Array.isArray(events) ? events.length > 0 : Boolean(events)));
36
+ }
37
+
38
+ export function modelYidaSchema(payload, { appType, formUuid }) {
39
+ const content = object(payload?.content ?? payload?.data ?? payload);
40
+ if (!Array.isArray(content?.pages)) throw new Error("YIDA_SCHEMA_UNAVAILABLE: the service did not return a readable schema; check form identity and design permissions");
41
+ const aliases = new Map();
42
+ for (const page of content.pages) {
43
+ for (const alias of page.componentAlias?.items ?? []) {
44
+ if (alias.fieldId && alias.alias) aliases.set(alias.fieldId, alias.alias);
45
+ }
46
+ }
47
+ const fields = [];
48
+ const dependencies = [];
49
+ const eventBindings = [];
50
+ let truncated = false;
51
+ function visit(node, parentFieldId = null, depth = 0) {
52
+ if (!node || typeof node !== "object") return;
53
+ if (depth > 100 || fields.length >= 2000) { truncated = true; return; }
54
+ const props = node.props ?? {};
55
+ let parent = parentFieldId;
56
+ if (typeof props.fieldId === "string" && /Field$/.test(node.componentName ?? "")) {
57
+ const source = Array.isArray(props.options) ? props.options :
58
+ [props.dataSource, props.dataSource?.options, props.dataSource?.data, props.dataSource?.list, props.dataSource?.values,
59
+ ...(props.defaultDataSource?.complexType === "custom" ? [props.defaultDataSource.options] : [])].find(Array.isArray);
60
+ const options = source?.slice(0, 100).map(item => {
61
+ if (item === null || typeof item !== "object") return { label: String(item), value: item };
62
+ const value = item.value ?? item.key ?? item.id;
63
+ return { label: label(item.label ?? item.text ?? item.name ?? item.title ?? value), ...(value !== undefined && ["string", "number", "boolean"].includes(typeof value) ? { value } : {}), ...(item.disabled === true ? { disabled: true } : {}) };
64
+ });
65
+ const validation = Array.isArray(props.validation) ? props.validation : [];
66
+ const requiredKnown = Object.hasOwn(props, "required") || validation.some(rule => rule?.type === "required" || rule?.required === true);
67
+ const formulaDetected = hasFormula(props.formula) || hasFormula(props.defaultDataSource?.formula) || hasFormula(props.complexValue?.formula);
68
+ const hasDataSource = Boolean(props.remoteDataSource || props.dataSourceLinkage || (props.dataSourceType && props.dataSourceType !== "custom") || (props.dataSource && !Array.isArray(props.dataSource) && !source));
69
+ const association = props.associationForm ?? props.associationFormConfig;
70
+ fields.push({
71
+ fieldId: props.fieldId, label: label(props.label), componentName: node.componentName,
72
+ alias: aliases.get(props.fieldId) ?? null, parentFieldId,
73
+ required: requiredKnown ? props.required === true || props.required === "true" || validation.some(rule => rule?.type === "required" || rule?.required === true) : null,
74
+ behavior: typeof props.behavior === "string" ? props.behavior : null,
75
+ hasDefault: [props.value, props.defaultValue, props.complexValue, props.defaultDataSource].some(value => value !== undefined && value !== null),
76
+ ...(options ? { options, optionCount: source.length, optionsTruncated: source.length > options.length } : {}),
77
+ hasFormula: formulaDetected, hasDynamicDataSource: hasDataSource
78
+ });
79
+ const refs = formReferences({ association, linkage: props.dataSourceLinkage, remote: props.remoteDataSource, source: props.dataSource, defaultSource: props.defaultDataSource, formula: props.formula, complexFormula: props.complexValue?.formula });
80
+ if (association || hasDataSource || formulaDetected || refs.length) {
81
+ dependencies.push({ fieldId: props.fieldId, kind: association ? "association" : hasDataSource ? "data_source" : "formula", formUuids: refs, resolution: "configuration_detected_runtime_not_verified" });
82
+ }
83
+ for (const [event, binding] of Object.entries(props)) {
84
+ if (!binding || typeof binding !== "object" || binding.type !== "JSExpression") continue;
85
+ const actions = Array.isArray(binding.events) ? binding.events.filter(item => item?.type === "actionRef").map(item => item.name ?? item.id).filter(value => typeof value === "string") : [];
86
+ if (actions.length) eventBindings.push({ fieldId: props.fieldId, event, actions });
87
+ }
88
+ parent = props.fieldId;
89
+ }
90
+ for (const child of Array.isArray(node.children) ? node.children : []) visit(child, parent, depth + 1);
91
+ }
92
+ for (const page of content.pages) for (const root of page.componentsTree ?? []) visit(root);
93
+ const runtime = modelYidaRules(content, appType);
94
+ truncated ||= runtime.truncated;
95
+ return {
96
+ kind: "yida_business_form", modelVersion: 2, appType, formUuid,
97
+ schemaHash: `sha256:${createHash("sha256").update(JSON.stringify(content)).digest("hex")}`,
98
+ fieldCount: fields.length, fields, dependencies, eventBindings, truncated,
99
+ verification: "schema_read_only", runtime,
100
+ unresolved: ["Dynamic options and effective employee permissions require runtime queries.", "Formula and event behavior is detected, not executed or fully reconstructed.", ...(truncated ? ["The field traversal limit was reached."] : [])],
101
+ // Default values and executable source are intentionally omitted from Agent output.
102
+ defaultsIncluded: false, executableSourceIncluded: false
103
+ };
104
+ }
105
+
106
+ export async function describeYidaForm(fargoStore, appType, formUuid, options = {}) {
107
+ validateYidaForm(appType, formUuid);
108
+ const query = new URLSearchParams({ formUuid, schemaVersion: "V5" });
109
+ const payload = await yidaRead(fargoStore, `/alibaba/web/${appType}/_view/query/formdesign/getFormSchema.json?${query}`, options);
110
+ return modelYidaSchema(payload, { appType, formUuid });
111
+ }
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolveYidaOwner } from "./yida-auth.mjs";
3
+
4
+ // Internal to one tool execution, never a caller-supplied identity or a global
5
+ // login cache. Every reuse checks local credentials; every write refreshes online.
6
+ export function createYidaTaskContext(store, options = {}) {
7
+ let fingerprint, identity, pending, closed = false;
8
+ const expiresAt = Date.now() + 5 * 60_000;
9
+ const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
10
+ const changed = () => { closed = true; throw new Error("YIDA_FARGO_ACCOUNT_CHANGED"); };
11
+ async function check() {
12
+ if (closed || Date.now() >= expiresAt) throw new Error("YIDA_TASK_EXPIRED");
13
+ const credential = await store.load();
14
+ const current = hash(credential ?? null);
15
+ if (fingerprint !== undefined && fingerprint !== current) changed();
16
+ fingerprint = current;
17
+ }
18
+ async function online() {
19
+ await check();
20
+ try {
21
+ const next = await resolveYidaOwner(store, options);
22
+ await check();
23
+ if (identity && hash(identity) !== hash(next)) changed();
24
+ identity = Object.freeze({ ...next });
25
+ return { ...identity };
26
+ } catch (error) { closed = true; throw error; }
27
+ }
28
+ async function owner() {
29
+ await check();
30
+ if (identity) return { ...identity };
31
+ if (!pending) pending = online().catch(error => { closed = true; throw error; });
32
+ return { ...await pending };
33
+ }
34
+ return {
35
+ options: { ...options, resolveYidaOwner: owner, revalidateYidaTaskOwner: online },
36
+ close() { closed = true; identity = undefined; pending = undefined; }
37
+ };
38
+ }
@@ -0,0 +1,91 @@
1
+ import { yidaStatus } from "./yida-auth.mjs";
2
+ import { describeYidaForm } from "./yida-schema.mjs";
3
+ import { getYidaBusinessBundle, listYidaBusinesses } from "./yida-business.mjs";
4
+ import { resolveYidaField } from "./yida-runtime.mjs";
5
+ import { cancelYidaRequest } from "./yida-cancellation.mjs";
6
+ import { submitYidaExpense } from "./yida-expense.mjs";
7
+
8
+ const profileProperty = { type: "string", pattern: "^[a-f0-9]{64}$", description: "Exact local authorization profile; required when multiple profiles exist." };
9
+ const tools = [
10
+ {
11
+ name: "yida_connection_status", description: "Read local YiDA connection metadata for the current verified Fargo account. Does not validate upstream access or return credentials.",
12
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
14
+ },
15
+ {
16
+ name: "yida_describe_form", description: "Read a YiDA form schema using the current user's local authorization. Return fields, static options and detected dependency references. Dynamic options, formulas and effective permissions remain unverified. No business records are written.",
17
+ inputSchema: { type: "object", properties: { appType: { type: "string", pattern: "^APP_[A-Za-z0-9_-]{1,200}$" }, formUuid: { type: "string", pattern: "^FORM-[A-Za-z0-9_-]{1,200}$" }, profile: profileProperty }, required: ["appType", "formUuid"], additionalProperties: false },
18
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
19
+ },
20
+ {
21
+ name: "yida_resolve_field", description: "Resolve one form field's configured options or linked default with personal OAuth and the live form context. Uses only read-only lookup formulas recorded in that schema; never executes page JavaScript or accepts raw expressions.",
22
+ inputSchema: { type: "object", properties: { appType: { type: "string" }, formUuid: { type: "string" }, fieldId: { type: "string" }, kind: { type: "string", enum: ["options", "linked_value"] }, values: { type: "object", maxProperties: 100, additionalProperties: { type: ["string", "number", "boolean", "null"] } }, profile: profileProperty }, required: ["appType", "formUuid", "fieldId"], additionalProperties: false },
23
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
24
+ },
25
+ {
26
+ name: "yida_business_list", description: "List the publisher's selected employee-facing business entries matching the local YiDA enterprise, with Chinese names, groups and aliases. Match the user's request and fetch only the selected bundle; ask the user to choose if ambiguous. Background dictionaries and copied/test forms are omitted from discovery. Listing does not prove personal submission permission or availability of a submission adapter. Credentials remain local.",
27
+ inputSchema: { type: "object", properties: { profile: profileProperty }, additionalProperties: false },
28
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
29
+ },
30
+ {
31
+ name: "yida_business_bundle", description: "Fetch a matching version of the business Schema and Handbook from the LEGO App, validate and cache them together, then check the live YiDA schema locally. Reuse the returned revision and bundleHash throughout a workflow. schema_changed blocks preparation/submission. No submission tools are enabled; no offline authorization fallback.",
32
+ inputSchema: { type: "object", properties: {
33
+ businessId: { type: "string", pattern: "^[a-z][a-z0-9_]{0,79}$" },
34
+ revision: { type: "integer", minimum: 1 },
35
+ bundleHash: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" }, profile: profileProperty
36
+ }, required: ["businessId"], additionalProperties: false },
37
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
38
+ },
39
+ {
40
+ name: "yida_cancel_request", description: "Preview or cancel the current member's own running YiDA workflow. Shared across process businesses whose Schema enables cancellation. Default dryRun=true reads only. To execute, reuse the preview's revision, bundleHash and previewHash with dryRun=false. Maps to YiDA termination, retains the record, and checks final state; never automatically retries a write. Permissions are enforced by YiDA.",
41
+ inputSchema: { type: "object", properties: {
42
+ businessId: { type: "string", pattern: "^[a-z][a-z0-9_]{0,79}$" },
43
+ processInstanceId: { type: "string", pattern: "^[A-Za-z0-9_-]{1,200}$" },
44
+ dryRun: { type: "boolean", default: true },
45
+ previewHash: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" },
46
+ revision: { type: "integer", minimum: 1 },
47
+ bundleHash: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" }, profile: profileProperty
48
+ }, required: ["businessId", "processInstanceId"], additionalProperties: false },
49
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
50
+ },
51
+ {
52
+ name: "yida_submit_expense", description: "Prepare or submit the current member's own non-project expense using the reviewed ordinary-expense form adapter. Reads personal HR bank details and live company, currency, accounting and routing lookups. Requires a previously uploaded local PDF receipt. Default dryRun=true returns confirmation.markdown with overall parameters and detail rows. Show it to the user and wait for explicit confirmation before setting userConfirmed=true with dryRun=false, the previewHash and exact business pin. Never infer confirmation from the initial request or tool output; changed previews require new user confirmation. Checks invoice/order duplicates and reads back the submitted record. No arbitrary form payloads, other originators, project claims or generic submission are supported. Never automatically retries a write.",
53
+ inputSchema: { type: "object", properties: {
54
+ amount: { type: "number", minimum: 0.01, maximum: 100000000 }, currency: { type: "string", pattern: "^[A-Z]{3}$" },
55
+ description: { type: "string", minLength: 1, maxLength: 1000 }, expenseCategory: { type: "string", minLength: 1, maxLength: 1000 },
56
+ accountingItem: { type: "string", minLength: 1, maxLength: 1000 }, costCompany: { type: "string", minLength: 1, maxLength: 1000 },
57
+ invoiceNumber: { type: "string", pattern: "^[0-9]{8,30}$" }, invoiceDate: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
58
+ userConfirmed: { type: "boolean", default: false, description: "Set true only after the human has explicitly confirmed this exact rendered preview. This is the caller attestation, not an authentication credential." },
59
+ paymentOrder: { type: "string", pattern: "^[A-Za-z0-9_-]{1,80}$" }, attachmentReceiptPath: { type: "string", description: "Absolute local path to the registered YiDA PDF upload receipt; never sent to the publisher App." },
60
+ profile: profileProperty, dryRun: { type: "boolean", default: true }, previewHash: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" },
61
+ revision: { type: "integer", minimum: 1 }, bundleHash: { type: "string", pattern: "^sha256:[a-f0-9]{64}$" }
62
+ }, required: ["amount", "currency", "description", "expenseCategory", "accountingItem", "costCompany", "invoiceNumber", "invoiceDate", "paymentOrder", "attachmentReceiptPath"], additionalProperties: false },
63
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
64
+ }
65
+ ];
66
+
67
+ export function localYidaTools() { return structuredClone(tools); }
68
+ export function isLocalYidaTool(name) { return typeof name === "string" && name.startsWith("yida_"); }
69
+
70
+ export async function callLocalYidaTool(store, name, args = {}, options = {}) {
71
+ const tool = tools.find(item => item.name === name);
72
+ if (!tool) throw new Error("Unknown local YiDA tool");
73
+ if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(key => !Object.hasOwn(tool.inputSchema.properties, key))) throw new Error("Invalid local YiDA tool arguments");
74
+ for (const required of tool.inputSchema.required ?? []) if (typeof args[required] !== tool.inputSchema.properties[required].type) throw new Error(`Missing YiDA argument: ${required}`);
75
+ if (args.profile !== undefined && (typeof args.profile !== "string" || !/^[a-f0-9]{64}$/.test(args.profile))) throw new Error("Invalid YiDA profile");
76
+ let value;
77
+ const localOptions = { ...options, profile: args.profile };
78
+ if (name === "yida_connection_status") value = await yidaStatus(store, options);
79
+ else if (name === "yida_describe_form") value = await describeYidaForm(store, args.appType, args.formUuid, localOptions);
80
+ else if (name === "yida_resolve_field") {
81
+ if (args.kind !== undefined && !["options", "linked_value"].includes(args.kind)) throw new Error("Invalid YiDA rule kind");
82
+ value = await resolveYidaField(store, args.appType, args.formUuid, args.fieldId, args.values ?? {}, { ...localOptions, kind: args.kind });
83
+ }
84
+ else if (name === "yida_business_list") value = await listYidaBusinesses(store, localOptions);
85
+ else if (name === "yida_submit_expense") value = await submitYidaExpense(store, args, localOptions);
86
+ else if (name === "yida_cancel_request") value = await cancelYidaRequest(store, args.businessId, args.processInstanceId, {
87
+ ...localOptions, dryRun: args.dryRun, previewHash: args.previewHash, revision: args.revision, bundleHash: args.bundleHash
88
+ });
89
+ else value = await getYidaBusinessBundle(store, args.businessId, { ...localOptions, revision: args.revision, bundleHash: args.bundleHash });
90
+ return { content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value, isError: false };
91
+ }
@@ -0,0 +1,18 @@
1
+ // A shared client capability declaration, not an upstream permission grant.
2
+ export function workflowCancellationCapability() {
3
+ return { enabled: true, mode: "terminate", localTool: "yida_cancel_request", ownRequestsOnly: true,
4
+ allowedStates: ["RUNNING"], terminalStates: ["TERMINATED", "CANCELED"],
5
+ permission: "upstream_authorization_required_on_execute", verification: "active_termination_not_live_verified" };
6
+ }
7
+
8
+ export const workflowCancellationHandbook = `
9
+ ## 申请撤销(共用流程能力)
10
+
11
+ 本业务复用本地工具 yida_cancel_request,传入 businessId 与 processInstanceId。默认 dryRun=true,仅核实当前账号、企业、本人发起、表单与流程归属、当前状态和业务包版本,并返回 pin 与 previewHash。预览不代表宜搭已授予终止权限。
12
+
13
+ 用户明确要求撤销后,用预览返回的 revision、bundleHash、previewHash 与 dryRun=false 执行。只处理本人仍在 RUNNING 且未有审批结果的实例;执行前再次回读,定义、内容或状态变化须重新预览。宜搭负责最终权限校验。普通数据表不适用此功能。
14
+
15
+ 撤销对应宜搭 terminateInstance,保留申请记录,不删除数据,也不自动冲销已经发生的付款或其他业务动作。TERMINATED/CANCELED 返回 already_cancelled,不重复写入;已完成实例返回 not_cancellable。成功需通过 API 回读确认终止状态。该共用实现的活动流程终止尚未实单验证,不应宣称每个流程都已验证可撤销。
16
+
17
+ 写入使用每个账号、实例的持久化操作记录。超时或结果不确定时只做回读,不自动重发;保存的成功回执也不会重新发起终止。明确的授权拒绝或写入前失败允许修复原因后重新预览、显式执行。OAuth、申请数据和操作记录保留本地,不经业务包分发应用转发。
18
+ `;
@@ -1,15 +0,0 @@
1
- # Fargo Website Connector Runtime
2
-
3
- The shared, declarative Website Connector compiler and execution runtime used
4
- by both Fargo CLI local debugging and the Fargo Browser Gateway.
5
-
6
- Connector definitions contain data only. They cannot ship executable code.
7
- Callers provide browser storage at execution time; the runtime never persists
8
- cookies or localStorage.
9
-
10
- Runtime `1.1.0` adds declarative AWS Cognito token refresh. `secretSources`
11
- select refresh-only browser values, while `headerSources` remains the complete
12
- set of values allowed in business requests. The runtime can exchange an
13
- expiring session through Cognito's `GetTokensFromRefreshToken` API and returns a
14
- new in-memory storage state; the hosting BrowserConnectionSession Durable Object
15
- is responsible for encrypting and persisting that state before use.
@@ -1,24 +0,0 @@
1
- {
2
- "name": "@fargowealth/website-connector-runtime",
3
- "version": "1.1.0",
4
- "description": "Shared declarative Website Connector runtime for Fargo AI",
5
- "type": "module",
6
- "exports": {
7
- ".": {
8
- "types": "./src/index.d.mts",
9
- "import": "./src/index.mjs"
10
- }
11
- },
12
- "files": [
13
- "src",
14
- "README.md"
15
- ],
16
- "engines": {
17
- "node": ">=20.19.0"
18
- },
19
- "private": true,
20
- "scripts": {
21
- "test": "node --test test/*.test.mjs"
22
- },
23
- "license": "UNLICENSED"
24
- }