webflow-mcp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.webflow/flows/_template/index.ts +14 -0
- package/.webflow/flows/_template/schema.json +11 -0
- package/.webflow/skills/README.md +3 -0
- package/README.md +215 -0
- package/dist/src/anti-block/provider-gate.d.ts +19 -0
- package/dist/src/anti-block/provider-gate.js +116 -0
- package/dist/src/cli/commands/approvals.d.ts +5 -0
- package/dist/src/cli/commands/approvals.js +69 -0
- package/dist/src/cli/commands/config.d.ts +4 -0
- package/dist/src/cli/commands/config.js +56 -0
- package/dist/src/cli/commands/create-flow.d.ts +4 -0
- package/dist/src/cli/commands/create-flow.js +27 -0
- package/dist/src/cli/commands/doctor.d.ts +19 -0
- package/dist/src/cli/commands/doctor.js +241 -0
- package/dist/src/cli/commands/fork.d.ts +4 -0
- package/dist/src/cli/commands/fork.js +19 -0
- package/dist/src/cli/commands/init.d.ts +4 -0
- package/dist/src/cli/commands/init.js +22 -0
- package/dist/src/cli/commands/login.d.ts +26 -0
- package/dist/src/cli/commands/login.js +145 -0
- package/dist/src/cli/commands/profiles.d.ts +40 -0
- package/dist/src/cli/commands/profiles.js +142 -0
- package/dist/src/cli/commands/run.d.ts +10 -0
- package/dist/src/cli/commands/run.js +13 -0
- package/dist/src/cli/commands/setup.d.ts +11 -0
- package/dist/src/cli/commands/setup.js +46 -0
- package/dist/src/cli/commands/worker.d.ts +29 -0
- package/dist/src/cli/commands/worker.js +30 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +336 -0
- package/dist/src/config/webflow-config.d.ts +35 -0
- package/dist/src/config/webflow-config.js +119 -0
- package/dist/src/errors/self-healing.d.ts +62 -0
- package/dist/src/errors/self-healing.js +77 -0
- package/dist/src/mcp/cli.d.ts +2 -0
- package/dist/src/mcp/cli.js +36 -0
- package/dist/src/mcp/relay-gateway.d.ts +24 -0
- package/dist/src/mcp/relay-gateway.js +208 -0
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.js +62 -0
- package/dist/src/mcp/tool-registry.d.ts +10 -0
- package/dist/src/mcp/tool-registry.js +366 -0
- package/dist/src/profiles/profile-manager.d.ts +33 -0
- package/dist/src/profiles/profile-manager.js +37 -0
- package/dist/src/profiles/profile-state.d.ts +31 -0
- package/dist/src/profiles/profile-state.js +122 -0
- package/dist/src/repertoire/local-repertoire.d.ts +3 -0
- package/dist/src/repertoire/local-repertoire.js +59 -0
- package/dist/src/runner/browser-session.d.ts +23 -0
- package/dist/src/runner/browser-session.js +58 -0
- package/dist/src/runner/cdp-session.d.ts +25 -0
- package/dist/src/runner/cdp-session.js +156 -0
- package/dist/src/runner/chrome.d.ts +18 -0
- package/dist/src/runner/chrome.js +56 -0
- package/dist/src/runner/flow-loader.d.ts +11 -0
- package/dist/src/runner/flow-loader.js +115 -0
- package/dist/src/runner/flow-runner.d.ts +70 -0
- package/dist/src/runner/flow-runner.js +378 -0
- package/dist/src/runner/types.d.ts +112 -0
- package/dist/src/runner/types.js +1 -0
- package/dist/src/schema/translator.d.ts +5 -0
- package/dist/src/schema/translator.js +104 -0
- package/dist/src/schema/types.d.ts +32 -0
- package/dist/src/schema/types.js +1 -0
- package/dist/src/security/flow-approvals.d.ts +34 -0
- package/dist/src/security/flow-approvals.js +324 -0
- package/dist/src/security/flow-release.d.ts +82 -0
- package/dist/src/security/flow-release.js +392 -0
- package/dist/src/security/official-flow-trust.d.ts +2 -0
- package/dist/src/security/official-flow-trust.js +16 -0
- package/dist/src/security/trusted-flow-keys.d.ts +8 -0
- package/dist/src/security/trusted-flow-keys.js +132 -0
- package/dist/src/shared/credentials.d.ts +21 -0
- package/dist/src/shared/credentials.js +99 -0
- package/dist/src/shared/errors.d.ts +6 -0
- package/dist/src/shared/errors.js +16 -0
- package/dist/src/shared/fs.d.ts +5 -0
- package/dist/src/shared/fs.js +23 -0
- package/dist/src/shared/paths.d.ts +112 -0
- package/dist/src/shared/paths.js +257 -0
- package/dist/src/shared/semaphore.d.ts +8 -0
- package/dist/src/shared/semaphore.js +23 -0
- package/dist/src/shared/taxonomy.d.ts +21 -0
- package/dist/src/shared/taxonomy.js +20 -0
- package/dist/src/worker/connection.d.ts +72 -0
- package/dist/src/worker/connection.js +427 -0
- package/dist/src/worker/lifecycle.d.ts +50 -0
- package/dist/src/worker/lifecycle.js +154 -0
- package/dist/src/worker/protocol.d.ts +188 -0
- package/dist/src/worker/protocol.js +156 -0
- package/dist/src/worker/worker-service.d.ts +112 -0
- package/dist/src/worker/worker-service.js +258 -0
- package/package.json +50 -0
- package/taxonomy.json +99 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { chmod, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
5
|
+
import { getFlowApprovalsPath, getFlowPendingApprovalsPath, getFlowReleaseStatePath } from '../shared/paths.js';
|
|
6
|
+
import { isVerifiedRemoteFlow, parseStrictJson } from './flow-release.js';
|
|
7
|
+
let writeQueue = Promise.resolve();
|
|
8
|
+
function serialized(operation) {
|
|
9
|
+
const result = writeQueue.then(() => withInterprocessLock(operation), () => withInterprocessLock(operation));
|
|
10
|
+
writeQueue = result.then(() => undefined, () => undefined);
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
async function withInterprocessLock(operation) {
|
|
14
|
+
const lockPath = `${getFlowApprovalsPath()}.lock`;
|
|
15
|
+
const lockToken = `${process.pid}:${randomUUID()}`;
|
|
16
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
17
|
+
let handle;
|
|
18
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
21
|
+
await handle.writeFile(`${lockToken}\n`, 'utf8');
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== 'EEXIST')
|
|
26
|
+
throw error;
|
|
27
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!handle)
|
|
31
|
+
fail('FLOW_APPROVAL_LOCK_TIMEOUT', `Timed out waiting for the local flow approval lock. If no worker or approval process is running, remove ${lockPath} manually.`);
|
|
32
|
+
try {
|
|
33
|
+
return await operation();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await handle.close().catch(() => undefined);
|
|
37
|
+
const currentOwner = await readFile(lockPath, 'utf8').catch(() => '');
|
|
38
|
+
if (currentOwner.trim() === lockToken)
|
|
39
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function fail(code, message, details) {
|
|
43
|
+
throw new WebFlowError(code, message, details);
|
|
44
|
+
}
|
|
45
|
+
function isRecord(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
function exactKeys(value, expected) {
|
|
49
|
+
const keys = Object.keys(value);
|
|
50
|
+
return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key));
|
|
51
|
+
}
|
|
52
|
+
function isBoundedString(value, max = 256) {
|
|
53
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
54
|
+
}
|
|
55
|
+
function isDigest(value) {
|
|
56
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
57
|
+
}
|
|
58
|
+
function isTimestamp(value) {
|
|
59
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
60
|
+
}
|
|
61
|
+
function parseApproval(value) {
|
|
62
|
+
if (!isRecord(value) || !exactKeys(value, ['trustDomain', 'flowName', 'flowDigest', 'sequence', 'version', 'releaseId', 'keyId', 'approvedAt', 'updatedAt']))
|
|
63
|
+
return undefined;
|
|
64
|
+
if (!isBoundedString(value.trustDomain) || !isBoundedString(value.flowName, 128) || !isDigest(value.flowDigest)
|
|
65
|
+
|| !Number.isSafeInteger(value.sequence) || value.sequence <= 0 || !isBoundedString(value.version)
|
|
66
|
+
|| !isDigest(value.releaseId) || !isBoundedString(value.keyId, 128) || !isTimestamp(value.approvedAt) || !isTimestamp(value.updatedAt))
|
|
67
|
+
return undefined;
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function parsePending(value) {
|
|
71
|
+
if (!isRecord(value) || !exactKeys(value, ['trustDomain', 'flowName', 'flowDigest', 'sequence', 'version', 'releaseId', 'keyId', 'receivedAt']))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!isBoundedString(value.trustDomain) || !isBoundedString(value.flowName, 128) || !isDigest(value.flowDigest)
|
|
74
|
+
|| !Number.isSafeInteger(value.sequence) || value.sequence <= 0 || !isBoundedString(value.version)
|
|
75
|
+
|| !isDigest(value.releaseId) || !isBoundedString(value.keyId, 128) || !isTimestamp(value.receivedAt))
|
|
76
|
+
return undefined;
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
function parseReleaseWatermark(value) {
|
|
80
|
+
if (!isRecord(value) || !exactKeys(value, ['trustDomain', 'highestSequence', 'releaseIdAtHighestSequence']))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (!isBoundedString(value.trustDomain) || !Number.isSafeInteger(value.highestSequence)
|
|
83
|
+
|| value.highestSequence <= 0 || !isDigest(value.releaseIdAtHighestSequence))
|
|
84
|
+
return undefined;
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
async function readStore(path, parse, empty, corruptCode = 'FLOW_APPROVAL_STORE_CORRUPT', stateName = 'flow approval state') {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = await readFile(path, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code === 'ENOENT')
|
|
94
|
+
return empty;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const result = parse(parseStrictJson(raw));
|
|
99
|
+
if (result !== undefined)
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof WebFlowError && error.code !== 'INVALID_FLOW_RELEASE_JSON')
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
fail(corruptCode, `Refusing to use corrupt ${stateName} at ${path}.`, { path });
|
|
107
|
+
}
|
|
108
|
+
function parseApprovalStore(value) {
|
|
109
|
+
if (!isRecord(value) || !exactKeys(value, ['format', 'formatVersion', 'approvals'])
|
|
110
|
+
|| value.format !== 'webflowmcp-flow-approvals' || value.formatVersion !== 1 || !Array.isArray(value.approvals))
|
|
111
|
+
return undefined;
|
|
112
|
+
const approvals = value.approvals.map(parseApproval);
|
|
113
|
+
if (approvals.some((entry) => entry === undefined))
|
|
114
|
+
return undefined;
|
|
115
|
+
const typed = approvals;
|
|
116
|
+
if (new Set(typed.map(keyOf)).size !== typed.length)
|
|
117
|
+
return undefined;
|
|
118
|
+
return { format: 'webflowmcp-flow-approvals', formatVersion: 1, approvals: typed };
|
|
119
|
+
}
|
|
120
|
+
function parsePendingStore(value) {
|
|
121
|
+
if (!isRecord(value) || !exactKeys(value, ['format', 'formatVersion', 'pending'])
|
|
122
|
+
|| value.format !== 'webflowmcp-flow-pending-approvals' || value.formatVersion !== 1 || !Array.isArray(value.pending))
|
|
123
|
+
return undefined;
|
|
124
|
+
const pending = value.pending.map(parsePending);
|
|
125
|
+
if (pending.some((entry) => entry === undefined))
|
|
126
|
+
return undefined;
|
|
127
|
+
const typed = pending;
|
|
128
|
+
if (new Set(typed.map(keyOf)).size !== typed.length)
|
|
129
|
+
return undefined;
|
|
130
|
+
return { format: 'webflowmcp-flow-pending-approvals', formatVersion: 1, pending: typed };
|
|
131
|
+
}
|
|
132
|
+
function parseFlowReleaseStateStore(value) {
|
|
133
|
+
if (!isRecord(value) || !exactKeys(value, ['format', 'formatVersion', 'trustDomains'])
|
|
134
|
+
|| value.format !== 'webflowmcp-flow-release-state' || value.formatVersion !== 1 || !Array.isArray(value.trustDomains))
|
|
135
|
+
return undefined;
|
|
136
|
+
const trustDomains = value.trustDomains.map(parseReleaseWatermark);
|
|
137
|
+
if (trustDomains.some((entry) => entry === undefined))
|
|
138
|
+
return undefined;
|
|
139
|
+
const typed = trustDomains;
|
|
140
|
+
if (new Set(typed.map((entry) => entry.trustDomain)).size !== typed.length)
|
|
141
|
+
return undefined;
|
|
142
|
+
return { format: 'webflowmcp-flow-release-state', formatVersion: 1, trustDomains: typed };
|
|
143
|
+
}
|
|
144
|
+
async function loadApprovals() {
|
|
145
|
+
return readStore(getFlowApprovalsPath(), parseApprovalStore, { format: 'webflowmcp-flow-approvals', formatVersion: 1, approvals: [] });
|
|
146
|
+
}
|
|
147
|
+
async function loadPending() {
|
|
148
|
+
return readStore(getFlowPendingApprovalsPath(), parsePendingStore, { format: 'webflowmcp-flow-pending-approvals', formatVersion: 1, pending: [] });
|
|
149
|
+
}
|
|
150
|
+
async function loadFlowReleaseState() {
|
|
151
|
+
return readStore(getFlowReleaseStatePath(), parseFlowReleaseStateStore, { format: 'webflowmcp-flow-release-state', formatVersion: 1, trustDomains: [] }, 'FLOW_RELEASE_STATE_CORRUPT', 'flow release state');
|
|
152
|
+
}
|
|
153
|
+
async function atomicWrite(path, value) {
|
|
154
|
+
await mkdir(dirname(path), { recursive: true });
|
|
155
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
158
|
+
await chmod(temporary, 0o600);
|
|
159
|
+
await rename(temporary, path);
|
|
160
|
+
await chmod(path, 0o600);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function keyOf(value) {
|
|
168
|
+
return `${value.trustDomain}\0${value.flowName}`;
|
|
169
|
+
}
|
|
170
|
+
function pendingFrom(verified, receivedAt = new Date().toISOString()) {
|
|
171
|
+
return {
|
|
172
|
+
trustDomain: verified.trustDomain,
|
|
173
|
+
flowName: verified.flowName,
|
|
174
|
+
flowDigest: verified.flowDigest,
|
|
175
|
+
sequence: verified.sequence,
|
|
176
|
+
version: verified.version,
|
|
177
|
+
releaseId: verified.releaseId,
|
|
178
|
+
keyId: verified.keyId,
|
|
179
|
+
receivedAt
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export async function assessAndRecordPending(verified) {
|
|
183
|
+
if (!isVerifiedRemoteFlow(verified))
|
|
184
|
+
fail('UNVERIFIED_REMOTE_FLOW', 'Only a cryptographically verified remote flow can affect approval state.');
|
|
185
|
+
return serialized(async () => {
|
|
186
|
+
const [approvals, pendingStore, releaseState] = await Promise.all([loadApprovals(), loadPending(), loadFlowReleaseState()]);
|
|
187
|
+
const watermark = releaseState.trustDomains.find((entry) => entry.trustDomain === verified.trustDomain);
|
|
188
|
+
if (watermark && verified.sequence < watermark.highestSequence) {
|
|
189
|
+
fail('FLOW_RELEASE_ROLLBACK', 'Flow release sequence is lower than the highest verified sequence for this trust domain.');
|
|
190
|
+
}
|
|
191
|
+
if (watermark && verified.sequence === watermark.highestSequence && verified.releaseId !== watermark.releaseIdAtHighestSequence) {
|
|
192
|
+
fail('FLOW_RELEASE_EQUIVOCATION', 'A different release was presented at the highest verified sequence for this trust domain.');
|
|
193
|
+
}
|
|
194
|
+
if (!watermark || verified.sequence > watermark.highestSequence) {
|
|
195
|
+
const advanced = {
|
|
196
|
+
trustDomain: verified.trustDomain,
|
|
197
|
+
highestSequence: verified.sequence,
|
|
198
|
+
releaseIdAtHighestSequence: verified.releaseId
|
|
199
|
+
};
|
|
200
|
+
if (watermark)
|
|
201
|
+
releaseState.trustDomains[releaseState.trustDomains.indexOf(watermark)] = advanced;
|
|
202
|
+
else
|
|
203
|
+
releaseState.trustDomains.push(advanced);
|
|
204
|
+
releaseState.trustDomains.sort((a, b) => a.trustDomain.localeCompare(b.trustDomain));
|
|
205
|
+
await atomicWrite(getFlowReleaseStatePath(), releaseState);
|
|
206
|
+
}
|
|
207
|
+
const key = keyOf(verified);
|
|
208
|
+
const approval = approvals.approvals.find((entry) => keyOf(entry) === key);
|
|
209
|
+
const existingPending = pendingStore.pending.find((entry) => keyOf(entry) === key);
|
|
210
|
+
if (approval) {
|
|
211
|
+
if (verified.sequence < approval.sequence)
|
|
212
|
+
fail('FLOW_RELEASE_ROLLBACK', 'Flow release sequence is lower than the locally approved sequence.');
|
|
213
|
+
if (verified.sequence === approval.sequence && verified.flowDigest !== approval.flowDigest) {
|
|
214
|
+
fail('FLOW_RELEASE_EQUIVOCATION', 'A different flow digest was presented at the approved sequence.');
|
|
215
|
+
}
|
|
216
|
+
if (verified.flowDigest === approval.flowDigest) {
|
|
217
|
+
if (existingPending && verified.sequence === existingPending.sequence && verified.flowDigest !== existingPending.flowDigest) {
|
|
218
|
+
fail('FLOW_RELEASE_EQUIVOCATION', 'Different flow digests were presented at the same sequence.');
|
|
219
|
+
}
|
|
220
|
+
if (verified.sequence > approval.sequence) {
|
|
221
|
+
const advanced = { ...approval, sequence: verified.sequence, version: verified.version, releaseId: verified.releaseId, keyId: verified.keyId, updatedAt: new Date().toISOString() };
|
|
222
|
+
approvals.approvals[approvals.approvals.indexOf(approval)] = advanced;
|
|
223
|
+
const clearPending = existingPending !== undefined && existingPending.sequence <= verified.sequence;
|
|
224
|
+
if (clearPending)
|
|
225
|
+
pendingStore.pending = pendingStore.pending.filter((entry) => keyOf(entry) !== key);
|
|
226
|
+
await atomicWrite(getFlowApprovalsPath(), approvals);
|
|
227
|
+
if (clearPending)
|
|
228
|
+
await atomicWrite(getFlowPendingApprovalsPath(), pendingStore);
|
|
229
|
+
return { status: 'approved', approval: advanced };
|
|
230
|
+
}
|
|
231
|
+
if (existingPending && existingPending.sequence === verified.sequence && existingPending.flowDigest === verified.flowDigest) {
|
|
232
|
+
pendingStore.pending = pendingStore.pending.filter((entry) => keyOf(entry) !== key);
|
|
233
|
+
await atomicWrite(getFlowPendingApprovalsPath(), pendingStore);
|
|
234
|
+
}
|
|
235
|
+
return { status: 'approved', approval };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (existingPending) {
|
|
239
|
+
if (verified.sequence < existingPending.sequence)
|
|
240
|
+
fail('FLOW_RELEASE_ROLLBACK', 'Flow release sequence is lower than the latest pending sequence.');
|
|
241
|
+
if (verified.sequence === existingPending.sequence && verified.flowDigest !== existingPending.flowDigest) {
|
|
242
|
+
fail('FLOW_RELEASE_EQUIVOCATION', 'Different flow digests were presented at the same sequence.');
|
|
243
|
+
}
|
|
244
|
+
if (verified.sequence === existingPending.sequence)
|
|
245
|
+
return { status: 'pending', pending: existingPending };
|
|
246
|
+
}
|
|
247
|
+
const pending = pendingFrom(verified);
|
|
248
|
+
const index = pendingStore.pending.findIndex((entry) => keyOf(entry) === key);
|
|
249
|
+
if (index === -1)
|
|
250
|
+
pendingStore.pending.push(pending);
|
|
251
|
+
else
|
|
252
|
+
pendingStore.pending[index] = pending;
|
|
253
|
+
pendingStore.pending.sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
|
|
254
|
+
await atomicWrite(getFlowPendingApprovalsPath(), pendingStore);
|
|
255
|
+
return { status: 'pending', pending };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
export async function listPending() {
|
|
259
|
+
return (await loadPending()).pending.map((entry) => ({ ...entry }));
|
|
260
|
+
}
|
|
261
|
+
export async function approvePending(flowName, trustDomain, expected) {
|
|
262
|
+
return serialized(async () => {
|
|
263
|
+
const [approvals, pendingStore] = await Promise.all([loadApprovals(), loadPending()]);
|
|
264
|
+
const matches = pendingStore.pending.filter((entry) => entry.flowName === flowName && (trustDomain === undefined || entry.trustDomain === trustDomain));
|
|
265
|
+
if (matches.length === 0)
|
|
266
|
+
fail('FLOW_APPROVAL_NOT_FOUND', `No pending approval found for flow ${flowName}.`);
|
|
267
|
+
if (matches.length > 1)
|
|
268
|
+
fail('FLOW_APPROVAL_AMBIGUOUS', `Flow ${flowName} is pending in more than one trust domain; specify trustDomain.`);
|
|
269
|
+
const pending = matches[0];
|
|
270
|
+
if (expected && (pending.flowDigest !== expected.flowDigest || pending.releaseId !== expected.releaseId || pending.sequence !== expected.sequence)) {
|
|
271
|
+
fail('FLOW_APPROVAL_CHANGED', 'The pending signed update changed during confirmation; inspect and approve it again.');
|
|
272
|
+
}
|
|
273
|
+
const key = keyOf(pending);
|
|
274
|
+
const current = approvals.approvals.find((entry) => keyOf(entry) === key);
|
|
275
|
+
if (current && pending.sequence < current.sequence)
|
|
276
|
+
fail('FLOW_RELEASE_ROLLBACK', 'Pending sequence is lower than the approved sequence.');
|
|
277
|
+
if (current && pending.sequence === current.sequence && pending.flowDigest !== current.flowDigest) {
|
|
278
|
+
fail('FLOW_RELEASE_EQUIVOCATION', 'Pending flow conflicts with the approved digest at the same sequence.');
|
|
279
|
+
}
|
|
280
|
+
const now = new Date().toISOString();
|
|
281
|
+
const approval = {
|
|
282
|
+
trustDomain: pending.trustDomain,
|
|
283
|
+
flowName: pending.flowName,
|
|
284
|
+
flowDigest: pending.flowDigest,
|
|
285
|
+
sequence: pending.sequence,
|
|
286
|
+
version: pending.version,
|
|
287
|
+
releaseId: pending.releaseId,
|
|
288
|
+
keyId: pending.keyId,
|
|
289
|
+
approvedAt: current?.approvedAt ?? now,
|
|
290
|
+
updatedAt: now
|
|
291
|
+
};
|
|
292
|
+
const index = approvals.approvals.findIndex((entry) => keyOf(entry) === key);
|
|
293
|
+
if (index === -1)
|
|
294
|
+
approvals.approvals.push(approval);
|
|
295
|
+
else
|
|
296
|
+
approvals.approvals[index] = approval;
|
|
297
|
+
approvals.approvals.sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
|
|
298
|
+
pendingStore.pending = pendingStore.pending.filter((entry) => keyOf(entry) !== key);
|
|
299
|
+
await atomicWrite(getFlowApprovalsPath(), approvals);
|
|
300
|
+
await atomicWrite(getFlowPendingApprovalsPath(), pendingStore);
|
|
301
|
+
return { ...approval };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
export async function revokeApproval(flowName, trustDomain, expected) {
|
|
305
|
+
return serialized(async () => {
|
|
306
|
+
const approvals = await loadApprovals();
|
|
307
|
+
const matches = approvals.approvals.filter((entry) => entry.flowName === flowName && (trustDomain === undefined || entry.trustDomain === trustDomain));
|
|
308
|
+
if (matches.length > 1)
|
|
309
|
+
fail('FLOW_APPROVAL_AMBIGUOUS', `Flow ${flowName} is approved in more than one trust domain; specify trustDomain.`);
|
|
310
|
+
if (matches.length === 0)
|
|
311
|
+
return false;
|
|
312
|
+
const approval = matches[0];
|
|
313
|
+
if (expected && (approval.flowDigest !== expected.flowDigest || approval.releaseId !== expected.releaseId || approval.sequence !== expected.sequence)) {
|
|
314
|
+
fail('FLOW_APPROVAL_CHANGED', 'The approved signed flow changed during confirmation; inspect it again before revoking.');
|
|
315
|
+
}
|
|
316
|
+
const key = keyOf(approval);
|
|
317
|
+
approvals.approvals = approvals.approvals.filter((entry) => keyOf(entry) !== key);
|
|
318
|
+
await atomicWrite(getFlowApprovalsPath(), approvals);
|
|
319
|
+
return true;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
export async function listApprovals() {
|
|
323
|
+
return (await loadApprovals()).approvals.map((entry) => ({ ...entry }));
|
|
324
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export declare const FLOW_RELEASE_FORMAT: "webflowmcp-flow-release";
|
|
2
|
+
export declare const FLOW_RELEASE_FORMAT_VERSION: 1;
|
|
3
|
+
export declare const FLOW_RUNTIME_API_VERSION: 1;
|
|
4
|
+
export declare const FLOW_RELEASE_RUNTIME_API_VERSION: 1;
|
|
5
|
+
export declare const FLOW_RELEASE_SIGNATURE_PREFIX = "webflowmcp-flow-release/v1\0";
|
|
6
|
+
export declare const FLOW_DIGEST_PREFIX = "webflowmcp-flow-digest/v1\0";
|
|
7
|
+
export declare const FLOW_RELEASE_LIMITS: Readonly<{
|
|
8
|
+
manifestBytes: number;
|
|
9
|
+
codeBytes: number;
|
|
10
|
+
schemaBytes: number;
|
|
11
|
+
capabilitiesBytes: number;
|
|
12
|
+
flows: 4096;
|
|
13
|
+
signatures: 32;
|
|
14
|
+
}>;
|
|
15
|
+
export interface FlowArtifactDescriptor {
|
|
16
|
+
sha256: string;
|
|
17
|
+
size: number;
|
|
18
|
+
}
|
|
19
|
+
export interface FlowReleaseManifestEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
code: FlowArtifactDescriptor;
|
|
22
|
+
schema: FlowArtifactDescriptor;
|
|
23
|
+
capabilities: FlowArtifactDescriptor;
|
|
24
|
+
}
|
|
25
|
+
export interface FlowReleaseManifest {
|
|
26
|
+
format: typeof FLOW_RELEASE_FORMAT;
|
|
27
|
+
formatVersion: typeof FLOW_RELEASE_FORMAT_VERSION;
|
|
28
|
+
runtimeApiVersion: typeof FLOW_RUNTIME_API_VERSION;
|
|
29
|
+
trustDomain: string;
|
|
30
|
+
sequence: number;
|
|
31
|
+
version: string;
|
|
32
|
+
sourceDigest: string;
|
|
33
|
+
taxonomy: FlowArtifactDescriptor;
|
|
34
|
+
flows: FlowReleaseManifestEntry[];
|
|
35
|
+
}
|
|
36
|
+
export interface FlowReleaseSignature {
|
|
37
|
+
algorithm: 'Ed25519';
|
|
38
|
+
keyId: string;
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
export interface SignedFlowRelease {
|
|
42
|
+
manifest: string;
|
|
43
|
+
signatures: FlowReleaseSignature[];
|
|
44
|
+
flowName: string;
|
|
45
|
+
code: string;
|
|
46
|
+
schema: string;
|
|
47
|
+
capabilities: string;
|
|
48
|
+
}
|
|
49
|
+
export interface FlowTrustPolicyEntry {
|
|
50
|
+
relayUrl: string;
|
|
51
|
+
trustDomain: string;
|
|
52
|
+
keyId: string;
|
|
53
|
+
publicKeySpkiPem: string;
|
|
54
|
+
minSequence?: number;
|
|
55
|
+
maxSequence?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface VerifyFlowReleaseOptions {
|
|
58
|
+
expectedRelayUrl: string;
|
|
59
|
+
expectedTrustDomain: string;
|
|
60
|
+
trustPolicy: readonly FlowTrustPolicyEntry[];
|
|
61
|
+
}
|
|
62
|
+
declare const verifiedRemoteFlowBrand: unique symbol;
|
|
63
|
+
export interface VerifiedRemoteFlow {
|
|
64
|
+
readonly [verifiedRemoteFlowBrand]: true;
|
|
65
|
+
readonly flowName: string;
|
|
66
|
+
readonly code: string;
|
|
67
|
+
readonly schema: Readonly<Record<string, unknown>>;
|
|
68
|
+
readonly capabilities: Readonly<Record<string, unknown>>;
|
|
69
|
+
readonly releaseId: string;
|
|
70
|
+
readonly sequence: number;
|
|
71
|
+
readonly version: string;
|
|
72
|
+
readonly keyId: string;
|
|
73
|
+
readonly trustDomain: string;
|
|
74
|
+
readonly flowDigest: string;
|
|
75
|
+
}
|
|
76
|
+
/** JSON parser that also rejects duplicate object keys, avoiding cross-parser ambiguity. */
|
|
77
|
+
export declare function parseStrictJson(raw: string): unknown;
|
|
78
|
+
export declare function parseFlowReleaseManifest(raw: string): FlowReleaseManifest;
|
|
79
|
+
export declare function deriveFlowReleaseKeyId(publicKeySpkiPem: string): string;
|
|
80
|
+
export declare function verifyFlowRelease(value: unknown, options: VerifyFlowReleaseOptions): VerifiedRemoteFlow;
|
|
81
|
+
export declare function isVerifiedRemoteFlow(value: unknown): value is VerifiedRemoteFlow;
|
|
82
|
+
export {};
|