@fargowealth/fargo-ai-cli 1.5.0 → 1.5.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/src/yida-auth.mjs DELETED
@@ -1,309 +0,0 @@
1
- // YiDA protocol adapted from OpenYida (MIT). See THIRD_PARTY_NOTICES.md.
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { readdir } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { join } from "node:path";
6
- import { authenticatedRequest } from "./api-client.mjs";
7
- import { openBrowser } from "./auth.mjs";
8
- import { createCredentialStore } from "./credential-store.mjs";
9
- import { createLoopbackReceiver } from "./loopback-receiver.mjs";
10
- import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL } from "./constants.mjs";
11
-
12
- const CLIENT_ID = "suite9xvlxxerybljwheo";
13
- const AUTH_PATH = "/openapi/cli/v1/auth";
14
- const PROFILE_ID = /^[a-f0-9]{64}$/;
15
-
16
- export function yidaOrigin(value) {
17
- let url;
18
- try { url = new URL(value); } catch { throw new Error("YIDA_INVALID_ORIGIN: expected an HTTPS YiDA origin"); }
19
- const host = url.hostname;
20
- if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash ||
21
- (url.pathname !== "/" && url.pathname !== "") ||
22
- !(host === "aliwork.com" || host.endsWith(".aliwork.com") || host === "yidaapps.com" || host.endsWith(".yidaapps.com"))) {
23
- throw new Error("YIDA_INVALID_ORIGIN: only HTTPS aliwork.com or yidaapps.com origins are supported");
24
- }
25
- return url.origin;
26
- }
27
-
28
- export async function resolveYidaOwner(fargoStore, options = {}) {
29
- if (options.resolveYidaOwner) return validateOwner(await options.resolveYidaOwner());
30
- const response = await authenticatedRequest(fargoStore,
31
- options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL, {
32
- method: "POST",
33
- headers: { Accept: "application/json", "Content-Type": "application/json", "MCP-Protocol-Version": "2025-11-25" },
34
- body: JSON.stringify({ jsonrpc: "2.0", id: randomBytes(12).toString("hex"), method: "tools/call", params: { name: "ping_who_am_i", arguments: {} } })
35
- }, options);
36
- if (!response.ok) throw new Error(`YIDA_FARGO_IDENTITY_REQUIRED: Fargo identity check failed (HTTP ${response.status})`);
37
- const rpc = await response.json();
38
- let identity = rpc.result?.structuredContent;
39
- if (!identity) {
40
- const block = rpc.result?.content?.find(item => item.type === "text");
41
- try { identity = JSON.parse(block?.text ?? "null"); } catch { /* Fail closed below. */ }
42
- }
43
- if (rpc.error || rpc.result?.isError || identity?.authenticated !== true) throw new Error("YIDA_FARGO_IDENTITY_REQUIRED: Fargo identity could not be verified");
44
- const credential = await fargoStore.load();
45
- return validateOwner({
46
- issuer: credential?.auth_url ?? options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL,
47
- accountId: identity.account?.id,
48
- organizationId: identity.account?.organizationId
49
- });
50
- }
51
-
52
- function validateOwner(owner) {
53
- if (!owner || [owner.issuer, owner.accountId, owner.organizationId].some(value => typeof value !== "string" || !value || value.length > 512)) {
54
- throw new Error("YIDA_FARGO_IDENTITY_REQUIRED: Fargo account and organization are required");
55
- }
56
- return { issuer: owner.issuer, accountId: owner.accountId, organizationId: owner.organizationId };
57
- }
58
-
59
- const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
60
- function profileDirectory(owner, options) {
61
- const home = options.yidaHome ?? homedir();
62
- const root = options.yidaDirectory ?? (process.platform === "win32" ? join(process.env.APPDATA || home, "Fargo AI", "yida") : join(home, ".fargo", "yida"));
63
- return join(root, digest(owner));
64
- }
65
-
66
- function profileStore(owner, profile, options) {
67
- if (!PROFILE_ID.test(profile)) throw new Error("YIDA_PROFILE_INVALID: expected an exact profile ID");
68
- const directory = profileDirectory(owner, options);
69
- return createCredentialStore({
70
- env: { FARGO_CREDENTIAL_STORE: "file", FARGO_CREDENTIAL_PATH: join(directory, `${profile}.json`), FARGO_REFRESH_LOCK_PATH: join(directory, `${profile}.lock`) }
71
- });
72
- }
73
-
74
- function checkSession(session, owner, profile) {
75
- if (!session || digest(session.owner) !== digest(owner) || session.profile !== profile || !session.corp_id || !session.user_id) {
76
- throw new Error("YIDA_PROFILE_INVALID: the saved authorization does not belong to this Fargo account");
77
- }
78
- yidaOrigin(session.base_url);
79
- yidaOrigin(session.auth_origin);
80
- return session;
81
- }
82
-
83
- function profileSummary(session) {
84
- return {
85
- profile: session.profile, corpId: session.corp_id, userId: session.user_id,
86
- baseUrl: session.base_url, expiresAt: session.expires_at ?? null, refreshable: Boolean(session.refresh_token)
87
- };
88
- }
89
-
90
- async function profilesForOwner(owner, options) {
91
- const entries = await readdir(profileDirectory(owner, options), { withFileTypes: true }).catch(error => {
92
- if (error.code === "ENOENT") return [];
93
- throw error;
94
- });
95
- const profiles = [];
96
- for (const entry of entries) {
97
- const profile = entry.name.replace(/\.json$/, "");
98
- if (!entry.name.endsWith(".json") || !PROFILE_ID.test(profile)) continue;
99
- const session = await profileStore(owner, profile, options).load();
100
- if (session) profiles.push(profileSummary(checkSession(session, owner, profile)));
101
- }
102
- return profiles.sort((a, b) => a.profile.localeCompare(b.profile));
103
- }
104
-
105
- export async function yidaStatus(fargoStore, options = {}) {
106
- const owner = await resolveYidaOwner(fargoStore, options);
107
- const profiles = await profilesForOwner(owner, options);
108
- return { connected: profiles.length > 0, validation: "local_metadata_only", profiles };
109
- }
110
-
111
- async function selectSession(fargoStore, options) {
112
- const owner = await resolveYidaOwner(fargoStore, options);
113
- let profile = options.profile;
114
- if (!profile) {
115
- const profiles = await profilesForOwner(owner, options);
116
- if (profiles.length !== 1) throw new Error(profiles.length ? "YIDA_PROFILE_REQUIRED: multiple authorizations exist; select --profile" : "YIDA_BINDING_REQUIRED: run 'fargo yida auth login' first");
117
- profile = profiles[0].profile;
118
- }
119
- const store = profileStore(owner, profile, options);
120
- const session = await store.load();
121
- if (!session) throw new Error("YIDA_BINDING_REQUIRED: authorization not found for the current Fargo account");
122
- return { owner, store, session: checkSession(session, owner, profile) };
123
- }
124
-
125
- async function jsonRequest(origin, path, body, options = {}, accessToken, encoding = "json") {
126
- const response = await (options.yidaFetchImpl ?? options.fetchImpl ?? fetch)(new URL(path, yidaOrigin(origin)), {
127
- method: body === undefined ? "GET" : "POST",
128
- redirect: "error",
129
- signal: AbortSignal.timeout(options.yidaTimeoutMs ?? 30_000),
130
- headers: {
131
- Accept: "application/json", "User-Agent": `fargo-cli/${CLI_VERSION}`,
132
- ...(body === undefined || encoding === "form" ? { Origin: yidaOrigin(origin), Referer: `${yidaOrigin(origin)}/`, "X-Requested-With": "XMLHttpRequest" } : {}),
133
- ...(body === undefined ? {} : { "Content-Type": encoding === "form" ? "application/x-www-form-urlencoded" : "application/json" }),
134
- ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
135
- },
136
- ...(body === undefined ? {} : { body: encoding === "form" ? new URLSearchParams(body).toString() : JSON.stringify(body) })
137
- });
138
- const payload = await response.json().catch(() => null);
139
- if (!payload && response.ok) throw new Error("YIDA_UNEXPECTED_RESPONSE: the endpoint returned no JSON schema; verify environment and personal-token support");
140
- // Do not echo upstream bodies: error messages can contain credentials or URLs.
141
- const code = payload?.code ?? payload?.errorCode ?? payload?.error;
142
- const authRequired = response.status === 401 || payload?.__needLogin === true || ["invalid_access_token", "expired_access_token", "access_token_expired", "missing_access_token"].includes(code);
143
- if (!response.ok || !payload || payload.success === false || payload.ok === false || authRequired) {
144
- const forbidden = response.status === 403 || (typeof code === "string" && code.startsWith("noPermission."));
145
- const error = new Error(forbidden ? "YIDA_UPSTREAM_FORBIDDEN: current user cannot access this resource" : `YIDA_UPSTREAM_ERROR: request failed (HTTP ${response.status})`);
146
- error.status = response.status;
147
- error.authRequired = authRequired;
148
- if (typeof code === "string" && /^[A-Za-z0-9_.-]{1,120}$/.test(code)) error.upstreamCode = code;
149
- throw error;
150
- }
151
- return payload;
152
- }
153
-
154
- function normalizedToken(payload, context, previous = {}) {
155
- const value = payload.data ?? payload.content ?? payload;
156
- const accessToken = value.access_token ?? value.accessToken;
157
- const corpId = value.corp_id ?? value.corpId ?? previous.corp_id;
158
- const userId = value.user_id ?? value.userId ?? previous.user_id;
159
- const refreshToken = value.refresh_token ?? value.refreshToken ?? previous.refresh_token ?? "";
160
- if (typeof accessToken !== "string" || !/^[\x21-\x7e]{1,16384}$/.test(accessToken) || typeof refreshToken !== "string" || refreshToken.length > 16384 || typeof corpId !== "string" || !corpId || typeof userId !== "string" || !userId) {
161
- throw new Error("YIDA_TOKEN_RESPONSE_INVALID: the service did not return a token and enterprise member identity");
162
- }
163
- if ((previous.corp_id && previous.corp_id !== corpId) || (previous.user_id && previous.user_id !== userId)) {
164
- throw new Error("YIDA_IDENTITY_CHANGED: reauthorize the intended enterprise account");
165
- }
166
- const expires = value.expires_at ?? value.expiresAt;
167
- const parsedExpiry = typeof expires === "string" && !/^\d+$/.test(expires) ? Date.parse(expires) : Number(expires);
168
- const expiresIn = Number(value.expires_in ?? value.expiresIn);
169
- return {
170
- ...context, access_token: accessToken,
171
- refresh_token: refreshToken,
172
- corp_id: corpId, user_id: userId,
173
- base_url: yidaOrigin(value.base_url ?? value.baseUrl ?? previous.base_url ?? context.auth_origin),
174
- expires_at: Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? (parsedExpiry < 1e12 ? parsedExpiry * 1000 : parsedExpiry) : (expiresIn > 0 ? Date.now() + expiresIn * 1000 : null)
175
- };
176
- }
177
-
178
- export async function yidaLogin(fargoStore, options = {}) {
179
- const owner = await resolveYidaOwner(fargoStore, options);
180
- const authOrigin = yidaOrigin(options.endpoint ?? "https://www.aliwork.com");
181
- const clientId = options.yidaClientId ?? CLIENT_ID;
182
- const state = randomBytes(32).toString("base64url");
183
- const receiver = await (options.yidaLoopbackFactory ?? createLoopbackReceiver)({ expectedState: state, callbackPath: "/oauth/callback", timeoutMs: options.authorizationTimeoutMs });
184
- const callbackPromise = receiver.waitForCallback();
185
- void callbackPromise.catch(() => {});
186
- try {
187
- const url = new URL("/oauth2/auth", authOrigin.endsWith("yidaapps.com") ? "https://login.dingtalk.io" : "https://login.dingtalk.com");
188
- url.search = new URLSearchParams({ client_id: clientId, redirect_uri: receiver.redirectUri, response_type: "code", scope: "openid corpid", state, prompt: "consent" }).toString();
189
- await options.onYidaAuthorization?.({ authorizationUrl: url.toString() });
190
- if (options.openBrowser !== false) await (options.browserOpener ?? openBrowser)(url.toString());
191
- const callback = await callbackPromise;
192
- if (callback.searchParams.get("state") !== state || callback.searchParams.has("error")) throw new Error("YIDA_OAUTH_FAILED: authorization callback could not be verified");
193
- const code = callback.searchParams.get("code") || callback.searchParams.get("authCode") || callback.searchParams.get("auth_code");
194
- if (!code) throw new Error("YIDA_OAUTH_FAILED: authorization code is missing");
195
- const payload = await jsonRequest(authOrigin, `${AUTH_PATH}/dingtalk/token`, { code, authCode: callback.searchParams.get("authCode") || code, redirectUri: receiver.redirectUri, state, clientId }, options);
196
- const session = normalizedToken(payload, { owner, auth_origin: authOrigin, client_id: clientId });
197
- if (options.corpId && options.corpId !== session.corp_id) throw new Error("YIDA_ENTERPRISE_MISMATCH: the authorized account belongs to a different enterprise");
198
- if (digest(await resolveYidaOwner(fargoStore, options)) !== digest(owner)) throw new Error("YIDA_FARGO_ACCOUNT_CHANGED: restart authorization for the current Fargo account");
199
- session.profile = digest([owner, session.auth_origin, session.client_id, session.corp_id, session.user_id]);
200
- const store = profileStore(owner, session.profile, options);
201
- await store.withRefreshLock(() => store.save(session));
202
- return { connected: true, ...profileSummary(session) };
203
- } finally { await receiver.close(); }
204
- }
205
-
206
- async function refreshSession(selected, options) {
207
- return selected.store.withRefreshLock(async () => {
208
- const latest = await selected.store.load();
209
- if (!latest) throw new Error("YIDA_BINDING_REVOKED: authorization was removed locally");
210
- checkSession(latest, selected.owner, selected.session.profile);
211
- if (latest.access_token !== selected.session.access_token || latest.refresh_token !== selected.session.refresh_token) return latest;
212
- if (!latest.refresh_token) throw new Error("YIDA_REAUTH_REQUIRED: authorization cannot be refreshed; run 'fargo yida auth login'");
213
- const payload = await jsonRequest(latest.auth_origin, `${AUTH_PATH}/refresh`, { refreshToken: latest.refresh_token, clientId: latest.client_id }, options);
214
- const next = normalizedToken(payload, { owner: selected.owner, auth_origin: latest.auth_origin, client_id: latest.client_id, profile: latest.profile }, latest);
215
- await selected.store.save(next);
216
- return next;
217
- });
218
- }
219
-
220
- export async function yidaRead(fargoStore, requestPath, options = {}) {
221
- const dataRead = /^\/dingtalk\/web\/APP_[A-Za-z0-9_-]+\/v1\/(?:process\/(?:getInstances|getInstanceById)|form\/(?:searchFormDatas|getFormDataById))\.json\?/.test(requestPath ?? "");
222
- const uploadSign = /^\/ossSign\?/.test(requestPath ?? "");
223
- const formBinding = /^\/APP_[A-Za-z0-9_-]+\/query\/formProcBinding\/getBindingByFormUuid\.json\?/.test(requestPath ?? "");
224
- const formNavigation = /^\/dingtalk\/web\/APP_[A-Za-z0-9_-]+\/query\/formnav\/getFormNavigationListByOrder\.json\?/.test(requestPath ?? "");
225
- if (typeof requestPath !== "string" || (!requestPath.startsWith("/alibaba/web/") && !dataRead && !uploadSign && !formBinding && !formNavigation) || requestPath.includes("\\") || requestPath.includes("..") || requestPath.startsWith("//")) throw new Error("YIDA_REQUEST_INVALID");
226
- const selected = await selectSession(fargoStore, options);
227
- try { return await jsonRequest(selected.session.base_url, requestPath, undefined, options, selected.session.access_token); }
228
- catch (error) {
229
- if (!error.authRequired) throw error;
230
- const refreshed = await refreshSession(selected, options);
231
- return jsonRequest(refreshed.base_url, requestPath, undefined, options, refreshed.access_token);
232
- }
233
- }
234
-
235
- // YiDA's page runtime uses POST for read-only lookup expressions. Admit only
236
- // the lookup DSL, never general formulas (which can contain write functions).
237
- export async function yidaLookupExpression(fargoStore, appType, formUuid, expression, options = {}) {
238
- if (!/^APP_[A-Za-z0-9_-]{1,200}$/.test(appType ?? "") || !/^FORM-[A-Za-z0-9_-]{1,200}$/.test(formUuid ?? "") ||
239
- typeof expression !== "string" || expression.length > 16000 || !expression.startsWith(`{${appType}}@FETCHDISTINCTDATA(`)) throw new Error("YIDA_LOOKUP_INVALID");
240
- let source = expression.slice(appType.length + 3);
241
- source = source.replace(/"(?:\\.|[^"\\])*"/g, value => { JSON.parse(value); return "0"; });
242
- const allowed = new Set(["FETCHDISTINCTDATA", "QUERYAND", "QUERYOR", "QUERYEQ", "QUERYNE", "QUERYLIKECI", "QUERYLIKE", "QUERYGT", "QUERYGE", "QUERYLT", "QUERYLE", "QUERYIN"]);
243
- const names = [...source.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)].map(match => match[1]);
244
- if (!names.length || names.some(name => !allowed.has(name)) || /[^\d\s(),.\-]/.test(source.replace(/[A-Z_]+\s*\(/g, "(").replace(/\b(?:true|false|null)\b/g, "0"))) throw new Error("YIDA_LOOKUP_INVALID");
245
- let depth = 0;
246
- for (const char of source) { if (char === "(") depth++; if (char === ")" && --depth < 0) throw new Error("YIDA_LOOKUP_INVALID"); }
247
- if (depth) throw new Error("YIDA_LOOKUP_INVALID");
248
- const selected = await selectSession(fargoStore, options);
249
- const path = `/${appType}/query/expression/evaluate.json`;
250
- const fields = { formUuid, expression, _api: "nattyFetch", _mock: "false" };
251
- try { return await jsonRequest(selected.session.base_url, path, fields, options, selected.session.access_token, "form"); }
252
- catch (error) {
253
- if (!error.authRequired) throw error;
254
- const refreshed = await refreshSession(selected, options);
255
- return jsonRequest(refreshed.base_url, path, fields, options, refreshed.access_token, "form");
256
- }
257
- }
258
-
259
- // Reviewed read-only adapters for the two connectors used by this business.
260
- // HR queries are always bound to the token's member, never a supplied user ID.
261
- export async function yidaApplicantConnector(fargoStore, connector, operation, departmentId, options = {}) {
262
- const hr = operation === "self_hr" && connector?.connectorId === "Http_5c72b42d47f14a1983767932f6c1d4f1" && connector.actionId === "getUserMessage" && connector.connection === 15597;
263
- const dept = operation === "department" && connector?.connectorId === "Http_75b8f8aef03c4b40815462052ba21a13" && connector.actionId === "operation-1" && connector.connection === 15624 && /^\d{1,20}$/.test(departmentId ?? "");
264
- if (!hr && !dept) throw new Error("YIDA_CONNECTOR_ADAPTER_UNSUPPORTED");
265
- const selected = await selectSession(fargoStore, options);
266
- const inputs = hr ? { "Content-Type": "application/json", Query: { agentid: "3195124669", userid_list: selected.session.user_id } } :
267
- { "Content-Type": "application/json", Body: { dept_id: departmentId } };
268
- const fields = { inputs: JSON.stringify(inputs), serviceInfo: JSON.stringify({ connectorInfo: {
269
- connectorId: connector.connectorId, actionId: connector.actionId, connection: connector.connection, type: "httpConnector"
270
- } }) };
271
- // Both operations are reads, but do not automatically replay connector calls.
272
- return jsonRequest(selected.session.base_url, "/query/publicService/invokeService.json", fields, options, selected.session.access_token, "form");
273
- }
274
-
275
- // Internal transport for explicitly prepared requests. Never replay a mutation,
276
- // even after a token rejection. Refresh through a read before calling this function.
277
- // The MCP bridge does not expose arbitrary paths or raw write arguments.
278
- export async function yidaPostFormOnce(fargoStore, requestPath, fields, options = {}) {
279
- let selected;
280
- try {
281
- if (typeof requestPath !== "string" || !(/^\/dingtalk\/web\/APP_[A-Za-z0-9_-]+\/v1\/process\/(?:startInstance|terminateInstance)\.json$/.test(requestPath) || requestPath === "/query/attach/uploadCallBack.json")) throw new Error("YIDA_REQUEST_INVALID");
282
- if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.values(fields).some(value => typeof value !== "string") || JSON.stringify(fields).length > 2_000_000) throw new Error("YIDA_WRITE_INVALID: expected bounded form-encoded string values");
283
- const expected = options.expectedYidaPrincipal;
284
- if (!expected || !options.profile || [expected.corpId, expected.userId, expected.baseUrl].some(value => typeof value !== "string" || !value)) throw new Error("YIDA_WRITE_PRINCIPAL_REQUIRED: bind the prepared request to its exact local profile and enterprise/member");
285
- await options.revalidateYidaTaskOwner?.();
286
- selected = await selectSession(fargoStore, options);
287
- if (options.expectedFargoOwner && digest(options.expectedFargoOwner) !== digest(selected.owner)) throw new Error("YIDA_FARGO_ACCOUNT_CHANGED");
288
- if (selected.session.corp_id !== expected.corpId || selected.session.user_id !== expected.userId || selected.session.base_url !== expected.baseUrl) throw new Error("YIDA_WRITE_PRINCIPAL_MISMATCH: the prepared request belongs to a different identity");
289
- if (digest(await resolveYidaOwner(fargoStore, options)) !== digest(selected.owner)) throw new Error("YIDA_FARGO_ACCOUNT_CHANGED");
290
- } catch (error) {
291
- error.yidaRequestSent = false;
292
- error.yidaMutationNotApplied = true;
293
- throw error;
294
- }
295
- return jsonRequest(selected.session.base_url, requestPath, fields, options, selected.session.access_token, "form");
296
- }
297
-
298
- export async function yidaLogout(fargoStore, options = {}) {
299
- const selected = await selectSession(fargoStore, options);
300
- return selected.store.withRefreshLock(async () => {
301
- const current = await selected.store.load();
302
- let remoteRevoked = false;
303
- if (current?.refresh_token) {
304
- try { await jsonRequest(current.auth_origin, `${AUTH_PATH}/logout`, { refreshToken: current.refresh_token }, options, current.access_token); remoteRevoked = true; } catch { /* Local removal is still effective. */ }
305
- }
306
- await selected.store.clear();
307
- return { disconnected: true, profile: selected.session.profile, remoteRevoked };
308
- });
309
- }
@@ -1,77 +0,0 @@
1
- // Shared by the local connector and the LEGO business-package publisher.
2
- export const YIDA_BUNDLE_FORMAT = "fargo.yida-business/v1";
3
- export const YIDA_CONNECTOR_APP_ID = "app_5c7114fa1a53";
4
- export const BUSINESS_ID = /^[a-z][a-z0-9_]{0,79}$/;
5
- export const HASH = /^sha256:[a-f0-9]{64}$/;
6
- export const MAX_BUNDLE_BYTES = 2_000_000;
7
-
8
- function fail(message) { throw new Error(`YIDA_BUNDLE_INVALID: ${message}`); }
9
- export function canonicalJson(value) {
10
- if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
11
- if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
12
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
13
- if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
14
- return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
15
- }
16
- fail("expected JSON values");
17
- }
18
- export async function bundleHash(value) {
19
- const { bundleHash: ignored, ...body } = value;
20
- const bytes = new TextEncoder().encode(canonicalJson(body));
21
- if (bytes.length > MAX_BUNDLE_BYTES) fail("package too large");
22
- const digest = await crypto.subtle.digest("SHA-256", bytes);
23
- return `sha256:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("")}`;
24
- }
25
-
26
- function versionParts(value) {
27
- if (typeof value !== "string" || !/^\d{1,6}\.\d{1,6}\.\d{1,6}$/.test(value)) fail("expected a release version x.y.z");
28
- return value.split(".").map(Number);
29
- }
30
- export function versionAtLeast(actual, required) {
31
- const a = versionParts(actual);
32
- const b = versionParts(required);
33
- for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
34
- return true;
35
- }
36
-
37
- export async function validateBusinessBundle(value, { cliVersion } = {}) {
38
- if (!value || value.format !== YIDA_BUNDLE_FORMAT || !BUSINESS_ID.test(value.businessId ?? "") ||
39
- !Number.isSafeInteger(value.revision) || value.revision < 1 || !HASH.test(value.bundleHash ?? "")) fail("identity, revision or hash");
40
- versionParts(value.version);
41
- versionParts(value.minCliVersion);
42
- if (cliVersion && !versionAtLeast(cliVersion, value.minCliVersion)) throw new Error("YIDA_CLI_UPGRADE_REQUIRED: this business package requires a newer Fargo CLI");
43
- if (typeof value.corpId !== "string" || !/^ding[A-Za-z0-9_-]{1,200}$/.test(value.corpId)) fail("enterprise identity");
44
- let origin;
45
- try { origin = new URL(value.yidaOrigin); } catch { fail("YiDA origin"); }
46
- if (origin.origin !== value.yidaOrigin || origin.protocol !== "https:" || origin.port ||
47
- !/^(?:[a-z0-9-]+\.)*(?:aliwork\.com|yidaapps\.com)$/.test(origin.hostname)) fail("YiDA origin");
48
- const schema = value.schema;
49
- if (!schema || schema.kind !== "yida_business_form" || schema.businessId !== value.businessId ||
50
- !/^APP_[A-Za-z0-9_-]{1,200}$/.test(schema.appType ?? "") || !/^FORM-[A-Za-z0-9_-]{1,200}$/.test(schema.formUuid ?? "") ||
51
- !HASH.test(schema.schemaHash ?? "") || !Array.isArray(schema.fields) || schema.fieldCount !== schema.fields.length ||
52
- ![1, 2].includes(schema.modelVersion) || typeof schema.truncated !== "boolean") fail("business schema");
53
- if (schema.modelVersion === 2 && (!schema.runtime || !Array.isArray(schema.runtime.rules) || schema.runtime.ruleCount !== schema.runtime.rules.length ||
54
- !Array.isArray(schema.runtime.connectors) || !Array.isArray(schema.runtime.scripts) || schema.runtime.formulasExecuted !== false)) fail("runtime rule metadata");
55
- // Generic submission stays disabled; workflow cancellation is a separate,
56
- // explicitly declared local capability and never grants upstream permission.
57
- if (schema.constraints?.submitEnabled !== false) fail("submission is not enabled by this connector release");
58
- const cancellation = schema.capabilities?.cancellation;
59
- if (cancellation?.enabled === true && (schema.formType !== "process" || cancellation.mode !== "terminate" ||
60
- cancellation.ownRequestsOnly !== true || cancellation.localTool !== "yida_cancel_request" ||
61
- JSON.stringify(cancellation.allowedStates) !== '["RUNNING"]' || JSON.stringify(cancellation.terminalStates) !== '["TERMINATED","CANCELED"]' ||
62
- !/^TPROC-[A-Za-z0-9_-]{1,200}$/.test(schema.entry?.processCode ?? ""))) fail("workflow cancellation declaration");
63
- if (typeof value.handbook !== "string" || !value.handbook.trim() || new TextEncoder().encode(value.handbook).length > 128_000) fail("handbook");
64
- if (await bundleHash(value) !== value.bundleHash) throw new Error("YIDA_BUNDLE_HASH_MISMATCH: schema and handbook package integrity check failed");
65
- return value;
66
- }
67
-
68
- export function businessBundleSummary(bundle) {
69
- return {
70
- businessId: bundle.businessId, name: bundle.schema.businessName ?? bundle.businessId,
71
- revision: bundle.revision, version: bundle.version, minCliVersion: bundle.minCliVersion,
72
- bundleHash: bundle.bundleHash, schemaHash: bundle.schema.schemaHash,
73
- corpId: bundle.corpId, yidaOrigin: bundle.yidaOrigin,
74
- appType: bundle.schema.appType, formUuid: bundle.schema.formUuid,
75
- submitEnabled: false, cancellationEnabled: bundle.schema.capabilities?.cancellation?.enabled === true
76
- };
77
- }
@@ -1,128 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { createCredentialStore } from "./credential-store.mjs";
6
- import { CLI_VERSION } from "./constants.mjs";
7
- import { resolveYidaOwner, yidaStatus } from "./yida-auth.mjs";
8
- import { describeYidaForm } from "./yida-schema.mjs";
9
- import { BUSINESS_ID, HASH, MAX_BUNDLE_BYTES, YIDA_CONNECTOR_APP_ID, businessBundleSummary, validateBusinessBundle } from "./yida-bundle-format.mjs";
10
-
11
- const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
12
- function validateBusinessId(id) {
13
- if (typeof id !== "string" || !BUSINESS_ID.test(id)) throw new Error("YIDA_BUSINESS_INVALID: expected a business ID");
14
- }
15
- async function context(store, options) {
16
- const owner = await resolveYidaOwner(store, options);
17
- const status = await yidaStatus(store, { ...options, resolveYidaOwner: async () => owner });
18
- if (options.profile !== undefined && !/^[a-f0-9]{64}$/.test(options.profile)) throw new Error("YIDA_PROFILE_INVALID: expected an exact profile ID");
19
- if (!options.profile && status.profiles.length > 1) throw new Error("YIDA_PROFILE_REQUIRED: select an authorization profile");
20
- const profile = options.profile ? status.profiles.find(item => item.profile === options.profile) : status.profiles[0];
21
- if (!profile) throw new Error("YIDA_BINDING_REQUIRED: run 'fargo yida auth login' first");
22
- const appId = options.yidaConnectorAppId ?? YIDA_CONNECTOR_APP_ID;
23
- if (!/^app_[a-zA-Z0-9_-]{1,150}$/.test(appId)) throw new Error("YIDA_CONNECTOR_INVALID: expected a LEGO App ID");
24
- const root = options.yidaBundleDirectory ?? (process.platform === "win32" ? join(process.env.APPDATA || homedir(), "Fargo AI", "yida-business") : join(options.yidaHome ?? homedir(), ".fargo", "yida-business"));
25
- return { owner, profile, appId, directory: join(root, digest({ owner, appId, profile: profile.profile })) };
26
- }
27
- async function remote(store, ctx, toolName, args, options) {
28
- // Import lazily: the MCP client also imports the local YiDA dispatch table.
29
- const { callMcpTool } = await import("./mcp-client.mjs");
30
- const result = await callMcpTool(store, "app_mcp_tool_call", { appId: ctx.appId, toolName, arguments: args }, options);
31
- if (result.isError) throw new Error("YIDA_BUSINESS_FETCH_FAILED: the business publisher denied or failed this request");
32
- let value = result.structuredContent;
33
- if (!value) {
34
- try { value = JSON.parse(result.content?.find(item => item.type === "text")?.text ?? "null"); } catch { /* Checked below. */ }
35
- }
36
- if (!value || typeof value !== "object" || value.error) throw new Error("YIDA_BUSINESS_FETCH_FAILED: invalid business publisher response");
37
- return value;
38
- }
39
- function checkEnterprise(bundle, ctx) {
40
- if (bundle.corpId !== ctx.profile.corpId || bundle.yidaOrigin !== ctx.profile.baseUrl) throw new Error("YIDA_BUSINESS_ENTERPRISE_MISMATCH: this package belongs to another YiDA enterprise");
41
- }
42
- async function checkOwner(store, ctx, options) {
43
- if (digest(await resolveYidaOwner(store, options)) !== digest(ctx.owner)) throw new Error("YIDA_FARGO_ACCOUNT_CHANGED: Fargo account changed during package retrieval");
44
- }
45
- async function regular(path, directory = false) {
46
- const details = await lstat(path);
47
- if (details.isSymbolicLink() || !(directory ? details.isDirectory() : details.isFile()) ||
48
- (typeof process.getuid === "function" && details.uid !== process.getuid())) throw new Error("YIDA_BUNDLE_CACHE_INVALID: cache must be owned by the current user and cannot be a symlink");
49
- return details;
50
- }
51
- async function load(path) {
52
- try {
53
- await regular(dirname(path), true);
54
- if ((await regular(path)).size > MAX_BUNDLE_BYTES + 4096) throw new Error("YIDA_BUNDLE_CACHE_INVALID: cache too large");
55
- return JSON.parse(await readFile(path, "utf8"));
56
- } catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
57
- }
58
- async function save(path, value) {
59
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
60
- await regular(dirname(path), true);
61
- const temporary = `${path}.${randomUUID()}.tmp`;
62
- let handle;
63
- try {
64
- handle = await open(temporary, "wx", 0o600);
65
- await handle.writeFile(JSON.stringify(value));
66
- await handle.sync();
67
- await handle.close();
68
- handle = undefined;
69
- await rename(temporary, path);
70
- } finally { await handle?.close(); await rm(temporary, { force: true }); }
71
- }
72
- function cacheLock(ctx, businessId, callback) {
73
- return createCredentialStore({ env: { FARGO_REFRESH_LOCK_PATH: join(ctx.directory, `${businessId}.lock`) } }).withRefreshLock(callback);
74
- }
75
-
76
- export async function listYidaBusinesses(store, options = {}) {
77
- const ctx = await context(store, options);
78
- const result = await remote(store, ctx, "list_businesses", {}, options);
79
- if (!Array.isArray(result.businesses)) throw new Error("YIDA_BUSINESS_FETCH_FAILED: expected a business catalog");
80
- await checkOwner(store, ctx, options);
81
- return { appId: ctx.appId, profile: ctx.profile.profile, businesses: result.businesses.filter(item => item.corpId === ctx.profile.corpId && item.yidaOrigin === ctx.profile.baseUrl) };
82
- }
83
-
84
- export async function getYidaBusinessBundle(store, businessId, options = {}) {
85
- validateBusinessId(businessId);
86
- if (options.revision !== undefined && (!Number.isSafeInteger(options.revision) || options.revision < 1)) throw new Error("YIDA_BUSINESS_INVALID: revision must be a positive integer");
87
- if (options.bundleHash !== undefined && (typeof options.bundleHash !== "string" || !HASH.test(options.bundleHash))) throw new Error("YIDA_BUSINESS_INVALID: invalid pinned bundle hash");
88
- const ctx = await context(store, options);
89
- const headPath = join(ctx.directory, `${businessId}.json`);
90
- const initial = await load(headPath);
91
- if (initial) await validateBusinessBundle(initial, { cliVersion: CLI_VERSION });
92
- const cached = options.revision && initial?.revision !== options.revision ? await load(join(ctx.directory, `${businessId}.${options.revision}.json`)) : initial;
93
- if (cached) await validateBusinessBundle(cached, { cliVersion: CLI_VERSION });
94
- // Every access checks the App's admission and current published version, even on a cache hit.
95
- const response = await remote(store, ctx, "get_business_bundle", {
96
- businessId, ...(options.revision ? { revision: options.revision } : {}), ...(cached ? { knownHash: cached.bundleHash } : {})
97
- }, options);
98
- const bundle = response.unchanged === true ? cached : response.bundle;
99
- if (!bundle || (response.unchanged && response.bundleHash !== cached?.bundleHash)) throw new Error("YIDA_BUSINESS_FETCH_FAILED: invalid unchanged response");
100
- await validateBusinessBundle(bundle, { cliVersion: CLI_VERSION });
101
- if (bundle.businessId !== businessId || (options.revision && bundle.revision !== options.revision)) throw new Error("YIDA_BUNDLE_IDENTITY_MISMATCH: unexpected business or revision");
102
- checkEnterprise(bundle, ctx);
103
- if (options.bundleHash && bundle.bundleHash !== options.bundleHash) throw new Error("YIDA_BUNDLE_PIN_MISMATCH: the workflow's pinned package has changed");
104
- await checkOwner(store, ctx, options);
105
- await cacheLock(ctx, businessId, async () => {
106
- const head = await load(headPath);
107
- if (head) await validateBusinessBundle(head, { cliVersion: CLI_VERSION });
108
- const revisionPath = join(ctx.directory, `${businessId}.${bundle.revision}.json`);
109
- const existing = await load(revisionPath);
110
- if ((existing && existing.bundleHash !== bundle.bundleHash) || (head?.revision === bundle.revision && head.bundleHash !== bundle.bundleHash)) throw new Error("YIDA_BUNDLE_REVISION_CONFLICT: a published revision is immutable");
111
- if (!options.revision && head && bundle.revision < head.revision) throw new Error("YIDA_BUNDLE_DOWNGRADE: publish a new revision to roll back business content");
112
- // Both documents are stored in one file; a partial download never replaces the active version.
113
- await save(revisionPath, bundle);
114
- if (!options.revision) await save(headPath, bundle);
115
- });
116
- const live = await describeYidaForm(store, bundle.schema.appType, bundle.schema.formUuid, { ...options, profile: ctx.profile.profile });
117
- await checkOwner(store, ctx, options);
118
- const matches = !live.truncated && !bundle.schema.truncated && live.schemaHash === bundle.schema.schemaHash;
119
- return {
120
- appId: ctx.appId, profile: ctx.profile.profile,
121
- status: matches ? "current" : "schema_changed",
122
- pin: { businessId, revision: bundle.revision, bundleHash: bundle.bundleHash },
123
- definition: businessBundleSummary(bundle), bundle,
124
- liveSchema: { schemaHash: live.schemaHash, fieldCount: live.fieldCount, truncated: live.truncated, matches },
125
- usableForSubmission: false,
126
- ...(matches ? {} : { actionRequired: "Re-read the YiDA definition and review the matching handbook before publishing a new business revision. Do not prepare or submit with this package." })
127
- };
128
- }
@@ -1,121 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { getYidaBusinessBundle } from "./yida-business.mjs";
5
- import { resolveYidaOwner, yidaStatus, yidaRead, yidaPostFormOnce } from "./yida-auth.mjs";
6
- import { BUSINESS_ID, HASH, canonicalJson } from "./yida-bundle-format.mjs";
7
- import { runRecordedYidaMutation } from "./yida-operation.mjs";
8
-
9
- const digest = value => createHash("sha256").update(canonicalJson(value)).digest("hex");
10
- const terminal = new Set(["TERMINATED", "CANCELED"]);
11
-
12
- export async function cancelYidaRequest(store, businessId, processInstanceId, options = {}) {
13
- if (typeof businessId !== "string" || !BUSINESS_ID.test(businessId) || typeof processInstanceId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(processInstanceId) ||
14
- options.dryRun !== undefined && typeof options.dryRun !== "boolean") throw new Error("YIDA_CANCELLATION_INVALID");
15
- const execute = options.dryRun === false;
16
- if (execute && (!HASH.test(options.previewHash ?? "") || !Number.isSafeInteger(options.revision) || options.revision < 1 || !HASH.test(options.bundleHash ?? ""))) {
17
- throw new Error("YIDA_CANCELLATION_PREVIEW_REQUIRED: preview this exact request and reuse its hash and business pin");
18
- }
19
- const owner = await resolveYidaOwner(store, options);
20
- const loaded = await getYidaBusinessBundle(store, businessId, options);
21
- if (loaded.status !== "current") throw new Error("YIDA_SCHEMA_CHANGED: cancellation is blocked until the business definition is reviewed");
22
- const schema = loaded.bundle.schema;
23
- const capability = schema.capabilities?.cancellation;
24
- if (schema.formType !== "process" || capability?.enabled !== true || capability.mode !== "terminate" ||
25
- capability.ownRequestsOnly !== true || capability.localTool !== "yida_cancel_request" ||
26
- typeof schema.entry?.processCode !== "string" || !/^TPROC-[A-Za-z0-9_-]{1,200}$/.test(schema.entry.processCode)) {
27
- throw new Error("YIDA_CANCELLATION_UNSUPPORTED: business must declare a process termination capability");
28
- }
29
- const status = await yidaStatus(store, options);
30
- const principal = status.profiles.find(profile => profile.profile === loaded.profile);
31
- if (!principal || principal.corpId !== loaded.bundle.corpId || principal.baseUrl !== loaded.bundle.yidaOrigin) throw new Error("YIDA_CANCELLATION_PRINCIPAL_MISMATCH");
32
- const local = { ...options, profile: principal.profile };
33
- async function checkOwner() {
34
- if (digest(owner) !== digest(await resolveYidaOwner(store, options))) throw new Error("YIDA_FARGO_ACCOUNT_CHANGED");
35
- }
36
- async function checkBinding() {
37
- const binding = await yidaRead(store, `/${schema.appType}/query/formProcBinding/getBindingByFormUuid.json?${new URLSearchParams({ formUuid: schema.formUuid })}`, local);
38
- if (binding.content?.appType !== schema.appType || binding.content?.procCode !== schema.entry.processCode) throw new Error("YIDA_CANCELLATION_PROCESS_MISMATCH");
39
- }
40
- await checkBinding();
41
- const path = `/dingtalk/web/${schema.appType}/v1/process/getInstanceById.json?${new URLSearchParams({ processInstanceId })}`;
42
- async function read() {
43
- await checkOwner();
44
- const result = await yidaRead(store, path, local);
45
- const record = result.content ?? result.result;
46
- if (record?.processInstanceId !== processInstanceId || record?.formUuid !== schema.formUuid || record?.processCode !== schema.entry.processCode ||
47
- record.originatorCorpId !== undefined && record.originatorCorpId !== principal.corpId) throw new Error("YIDA_CANCELLATION_TARGET_MISMATCH");
48
- if (record.originator?.userId !== principal.userId) throw new Error("YIDA_CANCELLATION_NOT_ORIGINATOR: only the member's own requests can be cancelled");
49
- await checkOwner();
50
- return record;
51
- }
52
- const record = await read();
53
- const fingerprint = value => `sha256:${digest({ pin: loaded.pin, profile: principal.profile, userId: principal.userId, processInstanceId,
54
- formUuid: value.formUuid, state: value.instanceStatus, approvedResult: value.approvedResult ?? null, data: value.data ?? null,
55
- version: value.version ?? null, modified: value.gmtModified ?? null, actioners: value.actioners ?? null })}`;
56
- const previewHash = fingerprint(record);
57
- const state = record.instanceStatus;
58
- const summary = {
59
- businessId, businessName: schema.businessName, processInstanceId, formUuid: schema.formUuid, originatorId: principal.userId,
60
- title: String(record.title ?? "").slice(0, 500), instanceStatus: state
61
- };
62
- const base = { operation: "terminate", pin: loaded.pin, previewHash, summary, verified: true, verification: "api_readback" };
63
- if (terminal.has(state)) return { ...base, status: "already_cancelled", requestSent: false };
64
- if (state !== "RUNNING" || record.approvedResult) return { ...base, status: "not_cancellable", requestSent: false, reason: "instance_not_running_or_already_decided" };
65
- // Whether this token is allowed to terminate remains authoritative at YiDA.
66
- if (!execute) return { ...base, status: "preview", requestSent: false, permission: "upstream_authorization_required_on_execute" };
67
- if (previewHash !== options.previewHash) throw new Error("YIDA_CANCELLATION_PREVIEW_CHANGED: request changed; inspect a fresh preview before cancelling");
68
-
69
- // Stable per identity + instance, independent of caller-generated request IDs.
70
- // A new call cannot bypass an uncertain result by choosing another request ID.
71
- const identity = { owner, profile: principal.profile, appType: schema.appType, formUuid: schema.formUuid, processInstanceId, action: "terminate" };
72
- const operationId = `cancel-${digest(identity)}`;
73
- const operationRoot = options.yidaOperationDirectory ?? (process.platform === "win32" ? join(process.env.APPDATA || homedir(), "Fargo AI", "yida-operations") : join(options.yidaHome ?? homedir(), ".fargo", "yida-operations"));
74
- const directory = join(operationRoot, digest({ owner, profile: principal.profile }));
75
- let attemptedHere = false;
76
- try {
77
- await runRecordedYidaMutation({ directory, requestId: operationId, payloadHash: `sha256:${digest(identity)}`, allowSafeRetry: true,
78
- send: async () => {
79
- // Re-read inside the per-instance lock; a parallel caller may have finished.
80
- try {
81
- await checkBinding();
82
- const current = await read();
83
- if (terminal.has(current.instanceStatus)) return { processInstanceId, requestSent: false };
84
- if (fingerprint(current) !== options.previewHash) throw new Error("YIDA_CANCELLATION_PREVIEW_CHANGED");
85
- await checkOwner();
86
- } catch (error) { error.yidaMutationNotApplied = true; throw error; }
87
- attemptedHere = true;
88
- let response;
89
- try {
90
- response = await yidaPostFormOnce(store, `/dingtalk/web/${schema.appType}/v1/process/terminateInstance.json`,
91
- { processInstanceId, _api: "nattyFetch", _mock: "false" }, { ...local, expectedYidaPrincipal: principal, expectedFargoOwner: owner });
92
- } catch (error) {
93
- if (error.status === 401 || error.status === 403 || error.authRequired || error.upstreamCode?.startsWith("noPermission.")) error.yidaMutationNotApplied = true;
94
- throw error;
95
- }
96
- if (response.success !== true) throw new Error("YIDA_CANCELLATION_UNVERIFIED");
97
- return { processInstanceId, requestSent: true };
98
- } });
99
- } catch (error) {
100
- if (error.yidaRequestSent === false) attemptedHere = false;
101
- if (["YIDA_CANCELLATION_PREVIEW_CHANGED", "YIDA_FARGO_ACCOUNT_CHANGED", "YIDA_CANCELLATION_NOT_ORIGINATOR", "YIDA_CANCELLATION_TARGET_MISMATCH", "YIDA_CANCELLATION_PROCESS_MISMATCH"].some(code => error.message.startsWith(code))) throw error;
102
- if (error.yidaMutationNotApplied === true) return { ...base,
103
- status: error.authRequired ? "authorization_required" : error.status === 403 || error.upstreamCode?.startsWith("noPermission.") ? "permission_denied" : "not_sent",
104
- verified: false, operationId, requestSent: attemptedHere, upstreamCode: error.upstreamCode ?? null,
105
- actionRequired: "Resolve the authorization or preflight failure, then inspect a fresh preview. No automatic retry was attempted." };
106
- // Read-only reconciliation, never retry a mutation (including a 401/timeout).
107
- try {
108
- const latest = await read();
109
- if (terminal.has(latest.instanceStatus)) return { ...base, status: "cancelled", summary: { ...summary, instanceStatus: latest.instanceStatus }, operationId,
110
- requestSent: attemptedHere, outcome: "terminal_state_observed_during_reconciliation" };
111
- } catch { /* Remain uncertain; do not expose upstream bodies. */ }
112
- return { ...base, status: "result_unknown", verified: false, operationId, requestSent: attemptedHere,
113
- upstreamCode: error.upstreamCode ?? null, actionRequired: "Read the instance state before any further action. Automatic retry is disabled." };
114
- }
115
- try {
116
- const latest = await read();
117
- if (terminal.has(latest.instanceStatus)) return { ...base, status: "cancelled", summary: { ...summary, instanceStatus: latest.instanceStatus }, operationId, requestSent: attemptedHere };
118
- } catch { /* An accepted request is not proof of the final state. */ }
119
- return { ...base, status: "verification_pending", verified: false, operationId, requestSent: attemptedHere,
120
- actionRequired: "Read the instance state to confirm termination. Do not resend." };
121
- }