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,392 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
2
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
3
|
+
export const FLOW_RELEASE_FORMAT = 'webflowmcp-flow-release';
|
|
4
|
+
export const FLOW_RELEASE_FORMAT_VERSION = 1;
|
|
5
|
+
export const FLOW_RUNTIME_API_VERSION = 1;
|
|
6
|
+
export const FLOW_RELEASE_RUNTIME_API_VERSION = FLOW_RUNTIME_API_VERSION;
|
|
7
|
+
export const FLOW_RELEASE_SIGNATURE_PREFIX = 'webflowmcp-flow-release/v1\0';
|
|
8
|
+
export const FLOW_DIGEST_PREFIX = 'webflowmcp-flow-digest/v1\0';
|
|
9
|
+
export const FLOW_RELEASE_LIMITS = Object.freeze({
|
|
10
|
+
manifestBytes: 1024 * 1024,
|
|
11
|
+
codeBytes: 4 * 1024 * 1024,
|
|
12
|
+
schemaBytes: 256 * 1024,
|
|
13
|
+
capabilitiesBytes: 256 * 1024,
|
|
14
|
+
flows: 4096,
|
|
15
|
+
signatures: 32
|
|
16
|
+
});
|
|
17
|
+
const verifiedValues = new WeakSet();
|
|
18
|
+
function fail(code, message, details) {
|
|
19
|
+
throw new WebFlowError(code, message, details);
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function assertExactKeys(value, required, optional = []) {
|
|
25
|
+
const allowed = new Set([...required, ...optional]);
|
|
26
|
+
const keys = Object.keys(value);
|
|
27
|
+
if (required.some((key) => !Object.hasOwn(value, key)) || keys.some((key) => !allowed.has(key))) {
|
|
28
|
+
fail('INVALID_FLOW_RELEASE', 'Flow release contains missing or unknown fields.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** JSON parser that also rejects duplicate object keys, avoiding cross-parser ambiguity. */
|
|
32
|
+
export function parseStrictJson(raw) {
|
|
33
|
+
let offset = 0;
|
|
34
|
+
const skipWhitespace = () => {
|
|
35
|
+
while (offset < raw.length && /[\x20\x09\x0a\x0d]/.test(raw[offset]))
|
|
36
|
+
offset += 1;
|
|
37
|
+
};
|
|
38
|
+
const parseString = () => {
|
|
39
|
+
const start = offset;
|
|
40
|
+
offset += 1;
|
|
41
|
+
while (offset < raw.length) {
|
|
42
|
+
const char = raw[offset];
|
|
43
|
+
if (char === '"') {
|
|
44
|
+
offset += 1;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw.slice(start, offset));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Invalid JSON string in flow release.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (char === '\\') {
|
|
53
|
+
offset += 2;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
offset += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Unterminated JSON string in flow release.');
|
|
60
|
+
};
|
|
61
|
+
const parseValue = () => {
|
|
62
|
+
skipWhitespace();
|
|
63
|
+
const char = raw[offset];
|
|
64
|
+
if (char === '"')
|
|
65
|
+
return parseString();
|
|
66
|
+
if (char === '{') {
|
|
67
|
+
offset += 1;
|
|
68
|
+
skipWhitespace();
|
|
69
|
+
const result = {};
|
|
70
|
+
const keys = new Set();
|
|
71
|
+
if (raw[offset] === '}') {
|
|
72
|
+
offset += 1;
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
while (offset < raw.length) {
|
|
76
|
+
if (raw[offset] !== '"')
|
|
77
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'JSON object key must be a string.');
|
|
78
|
+
const key = parseString();
|
|
79
|
+
if (keys.has(key))
|
|
80
|
+
fail('INVALID_FLOW_RELEASE_JSON', `Duplicate JSON object key: ${key}`);
|
|
81
|
+
keys.add(key);
|
|
82
|
+
skipWhitespace();
|
|
83
|
+
if (raw[offset] !== ':')
|
|
84
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Missing colon after JSON object key.');
|
|
85
|
+
offset += 1;
|
|
86
|
+
Object.defineProperty(result, key, { value: parseValue(), enumerable: true, configurable: true, writable: true });
|
|
87
|
+
skipWhitespace();
|
|
88
|
+
if (raw[offset] === '}') {
|
|
89
|
+
offset += 1;
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
if (raw[offset] !== ',')
|
|
93
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Missing comma in JSON object.');
|
|
94
|
+
offset += 1;
|
|
95
|
+
skipWhitespace();
|
|
96
|
+
}
|
|
97
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Unterminated JSON object.');
|
|
98
|
+
}
|
|
99
|
+
if (char === '[') {
|
|
100
|
+
offset += 1;
|
|
101
|
+
skipWhitespace();
|
|
102
|
+
const result = [];
|
|
103
|
+
if (raw[offset] === ']') {
|
|
104
|
+
offset += 1;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
while (offset < raw.length) {
|
|
108
|
+
result.push(parseValue());
|
|
109
|
+
skipWhitespace();
|
|
110
|
+
if (raw[offset] === ']') {
|
|
111
|
+
offset += 1;
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
if (raw[offset] !== ',')
|
|
115
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Missing comma in JSON array.');
|
|
116
|
+
offset += 1;
|
|
117
|
+
}
|
|
118
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Unterminated JSON array.');
|
|
119
|
+
}
|
|
120
|
+
const start = offset;
|
|
121
|
+
while (offset < raw.length && !/[\x20\x09\x0a\x0d,\]}]/.test(raw[offset]))
|
|
122
|
+
offset += 1;
|
|
123
|
+
if (start === offset)
|
|
124
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Invalid JSON value.');
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(raw.slice(start, offset));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Invalid JSON value.');
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const parsed = parseValue();
|
|
133
|
+
skipWhitespace();
|
|
134
|
+
if (offset !== raw.length)
|
|
135
|
+
fail('INVALID_FLOW_RELEASE_JSON', 'Unexpected data after JSON value.');
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
function nonEmptyString(value, label, max = 256) {
|
|
139
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
140
|
+
fail('INVALID_FLOW_RELEASE', `${label} must be a non-empty bounded string without control characters.`);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
function positiveSafeInteger(value, label) {
|
|
145
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
146
|
+
fail('INVALID_FLOW_RELEASE', `${label} must be a positive safe integer.`);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
function parseArtifact(value, label, maxBytes) {
|
|
151
|
+
if (!isRecord(value))
|
|
152
|
+
fail('INVALID_FLOW_RELEASE', `${label} descriptor must be an object.`);
|
|
153
|
+
assertExactKeys(value, ['sha256', 'size']);
|
|
154
|
+
if (typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256)) {
|
|
155
|
+
fail('INVALID_FLOW_RELEASE', `${label}.sha256 must be a lowercase SHA-256 digest.`);
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isSafeInteger(value.size) || value.size < 0 || value.size > maxBytes) {
|
|
158
|
+
fail('FLOW_RELEASE_TOO_LARGE', `${label}.size exceeds the allowed artifact size.`);
|
|
159
|
+
}
|
|
160
|
+
return { sha256: value.sha256, size: value.size };
|
|
161
|
+
}
|
|
162
|
+
export function parseFlowReleaseManifest(raw) {
|
|
163
|
+
if (typeof raw !== 'string' || Buffer.byteLength(raw, 'utf8') > FLOW_RELEASE_LIMITS.manifestBytes) {
|
|
164
|
+
fail('FLOW_RELEASE_TOO_LARGE', 'Flow release manifest exceeds the allowed size.');
|
|
165
|
+
}
|
|
166
|
+
const value = parseStrictJson(raw);
|
|
167
|
+
if (!isRecord(value))
|
|
168
|
+
fail('INVALID_FLOW_RELEASE', 'Flow release manifest must be an object.');
|
|
169
|
+
assertExactKeys(value, ['format', 'formatVersion', 'runtimeApiVersion', 'trustDomain', 'sequence', 'version', 'sourceDigest', 'taxonomy', 'flows']);
|
|
170
|
+
if (value.format !== FLOW_RELEASE_FORMAT || value.formatVersion !== FLOW_RELEASE_FORMAT_VERSION || value.runtimeApiVersion !== FLOW_RUNTIME_API_VERSION) {
|
|
171
|
+
fail('UNSUPPORTED_FLOW_RELEASE', 'Unsupported flow release format or runtime API version.');
|
|
172
|
+
}
|
|
173
|
+
const trustDomain = nonEmptyString(value.trustDomain, 'trustDomain');
|
|
174
|
+
const sequence = positiveSafeInteger(value.sequence, 'sequence');
|
|
175
|
+
const version = nonEmptyString(value.version, 'version');
|
|
176
|
+
if (typeof value.sourceDigest !== 'string' || !/^[a-f0-9]{64}$/.test(value.sourceDigest)) {
|
|
177
|
+
fail('INVALID_FLOW_RELEASE', 'sourceDigest must be a lowercase SHA-256 digest.');
|
|
178
|
+
}
|
|
179
|
+
const taxonomy = parseArtifact(value.taxonomy, 'taxonomy', FLOW_RELEASE_LIMITS.schemaBytes);
|
|
180
|
+
if (!Array.isArray(value.flows) || value.flows.length > FLOW_RELEASE_LIMITS.flows) {
|
|
181
|
+
fail('INVALID_FLOW_RELEASE', 'flows must be a bounded array.');
|
|
182
|
+
}
|
|
183
|
+
const flows = [];
|
|
184
|
+
let previousName;
|
|
185
|
+
for (const rawFlow of value.flows) {
|
|
186
|
+
if (!isRecord(rawFlow))
|
|
187
|
+
fail('INVALID_FLOW_RELEASE', 'Each flow manifest entry must be an object.');
|
|
188
|
+
assertExactKeys(rawFlow, ['name', 'code', 'schema', 'capabilities']);
|
|
189
|
+
const name = nonEmptyString(rawFlow.name, 'flow name', 128);
|
|
190
|
+
if (previousName !== undefined && name <= previousName) {
|
|
191
|
+
fail('INVALID_FLOW_RELEASE', 'Flow manifest entries must have unique names sorted in ascending byte order.');
|
|
192
|
+
}
|
|
193
|
+
previousName = name;
|
|
194
|
+
flows.push({
|
|
195
|
+
name,
|
|
196
|
+
code: parseArtifact(rawFlow.code, `${name}.code`, FLOW_RELEASE_LIMITS.codeBytes),
|
|
197
|
+
schema: parseArtifact(rawFlow.schema, `${name}.schema`, FLOW_RELEASE_LIMITS.schemaBytes),
|
|
198
|
+
capabilities: parseArtifact(rawFlow.capabilities, `${name}.capabilities`, FLOW_RELEASE_LIMITS.capabilitiesBytes)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
format: FLOW_RELEASE_FORMAT,
|
|
203
|
+
formatVersion: 1,
|
|
204
|
+
runtimeApiVersion: 1,
|
|
205
|
+
trustDomain,
|
|
206
|
+
sequence,
|
|
207
|
+
version,
|
|
208
|
+
sourceDigest: value.sourceDigest,
|
|
209
|
+
taxonomy,
|
|
210
|
+
flows
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function parseSignedRelease(value) {
|
|
214
|
+
if (!isRecord(value))
|
|
215
|
+
fail('INVALID_FLOW_RELEASE', 'Signed flow release must be an object.');
|
|
216
|
+
assertExactKeys(value, ['manifest', 'signatures', 'flowName', 'code', 'schema', 'capabilities']);
|
|
217
|
+
if (typeof value.manifest !== 'string' || typeof value.code !== 'string' || typeof value.schema !== 'string' || typeof value.capabilities !== 'string') {
|
|
218
|
+
fail('INVALID_FLOW_RELEASE', 'Selected flow artifacts and manifest must be exact strings.');
|
|
219
|
+
}
|
|
220
|
+
if (Buffer.byteLength(value.manifest, 'utf8') > FLOW_RELEASE_LIMITS.manifestBytes
|
|
221
|
+
|| Buffer.byteLength(value.code, 'utf8') > FLOW_RELEASE_LIMITS.codeBytes
|
|
222
|
+
|| Buffer.byteLength(value.schema, 'utf8') > FLOW_RELEASE_LIMITS.schemaBytes
|
|
223
|
+
|| Buffer.byteLength(value.capabilities, 'utf8') > FLOW_RELEASE_LIMITS.capabilitiesBytes) {
|
|
224
|
+
fail('FLOW_RELEASE_TOO_LARGE', 'Signed flow release exceeds an artifact size limit.');
|
|
225
|
+
}
|
|
226
|
+
const flowName = nonEmptyString(value.flowName, 'flowName', 128);
|
|
227
|
+
if (!Array.isArray(value.signatures) || value.signatures.length === 0 || value.signatures.length > FLOW_RELEASE_LIMITS.signatures) {
|
|
228
|
+
fail('INVALID_FLOW_RELEASE', 'signatures must be a non-empty bounded array.');
|
|
229
|
+
}
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
const signatures = value.signatures.map((rawSignature) => {
|
|
232
|
+
if (!isRecord(rawSignature))
|
|
233
|
+
fail('INVALID_FLOW_RELEASE', 'Each signature must be an object.');
|
|
234
|
+
assertExactKeys(rawSignature, ['algorithm', 'keyId', 'value']);
|
|
235
|
+
if (rawSignature.algorithm !== 'Ed25519')
|
|
236
|
+
fail('UNSUPPORTED_FLOW_RELEASE_SIGNATURE', 'Only Ed25519 flow release signatures are accepted.');
|
|
237
|
+
const keyId = nonEmptyString(rawSignature.keyId, 'signature keyId', 128);
|
|
238
|
+
if (seen.has(keyId))
|
|
239
|
+
fail('INVALID_FLOW_RELEASE', `Duplicate signature keyId: ${keyId}`);
|
|
240
|
+
seen.add(keyId);
|
|
241
|
+
if (typeof rawSignature.value !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(rawSignature.value)) {
|
|
242
|
+
fail('INVALID_FLOW_RELEASE_SIGNATURE', 'Ed25519 signature must be canonical base64.');
|
|
243
|
+
}
|
|
244
|
+
const bytes = Buffer.from(rawSignature.value, 'base64');
|
|
245
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== rawSignature.value) {
|
|
246
|
+
fail('INVALID_FLOW_RELEASE_SIGNATURE', 'Ed25519 signature must be 64 canonical base64 bytes.');
|
|
247
|
+
}
|
|
248
|
+
return { algorithm: 'Ed25519', keyId, value: rawSignature.value };
|
|
249
|
+
});
|
|
250
|
+
return { manifest: value.manifest, signatures, flowName, code: value.code, schema: value.schema, capabilities: value.capabilities };
|
|
251
|
+
}
|
|
252
|
+
function sha256(value) {
|
|
253
|
+
return createHash('sha256').update(value).digest('hex');
|
|
254
|
+
}
|
|
255
|
+
export function deriveFlowReleaseKeyId(publicKeySpkiPem) {
|
|
256
|
+
try {
|
|
257
|
+
const key = createPublicKey({ key: publicKeySpkiPem, format: 'pem', type: 'spki' });
|
|
258
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
259
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trusted flow release key must be Ed25519.');
|
|
260
|
+
return sha256(key.export({ format: 'der', type: 'spki' }));
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof WebFlowError)
|
|
264
|
+
throw error;
|
|
265
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trusted flow release key is not a valid SPKI PEM Ed25519 key.');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function verifyArtifact(value, descriptor, maxBytes, label) {
|
|
269
|
+
const size = Buffer.byteLength(value, 'utf8');
|
|
270
|
+
if (size > maxBytes)
|
|
271
|
+
fail('FLOW_RELEASE_TOO_LARGE', `${label} exceeds the allowed artifact size.`);
|
|
272
|
+
if (size !== descriptor.size || sha256(value) !== descriptor.sha256) {
|
|
273
|
+
fail('FLOW_RELEASE_ARTIFACT_MISMATCH', `${label} does not match its signed size and SHA-256 digest.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function parseJsonObject(raw, label) {
|
|
277
|
+
const parsed = parseStrictJson(raw);
|
|
278
|
+
if (!isRecord(parsed))
|
|
279
|
+
fail('INVALID_FLOW_RELEASE_ARTIFACT', `${label} must contain a JSON object.`);
|
|
280
|
+
return deepFreeze(parsed);
|
|
281
|
+
}
|
|
282
|
+
function deepFreeze(value) {
|
|
283
|
+
if (typeof value === 'object' && value !== null) {
|
|
284
|
+
for (const child of Object.values(value))
|
|
285
|
+
deepFreeze(child);
|
|
286
|
+
Object.freeze(value);
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
function validatePolicy(entry) {
|
|
291
|
+
if (typeof entry.relayUrl !== 'string' || entry.relayUrl.length === 0 || typeof entry.trustDomain !== 'string' || entry.trustDomain.length === 0
|
|
292
|
+
|| typeof entry.keyId !== 'string' || entry.keyId.length === 0 || typeof entry.publicKeySpkiPem !== 'string' || entry.publicKeySpkiPem.length === 0) {
|
|
293
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trust policy relay URL, domain, key ID, and SPKI PEM must be non-empty strings.');
|
|
294
|
+
}
|
|
295
|
+
if (entry.minSequence !== undefined && (!Number.isSafeInteger(entry.minSequence) || entry.minSequence <= 0)) {
|
|
296
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trust policy minSequence must be a positive safe integer.');
|
|
297
|
+
}
|
|
298
|
+
if (entry.maxSequence !== undefined && (!Number.isSafeInteger(entry.maxSequence) || entry.maxSequence <= 0)) {
|
|
299
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trust policy maxSequence must be a positive safe integer.');
|
|
300
|
+
}
|
|
301
|
+
if (entry.minSequence !== undefined && entry.maxSequence !== undefined && entry.minSequence > entry.maxSequence) {
|
|
302
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trust policy sequence range is empty.');
|
|
303
|
+
}
|
|
304
|
+
if (deriveFlowReleaseKeyId(entry.publicKeySpkiPem) !== entry.keyId) {
|
|
305
|
+
fail('INVALID_FLOW_TRUST_POLICY', 'Trust policy keyId must equal the SHA-256 digest of its public SPKI DER bytes.');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function sequenceAllowed(entry, sequence) {
|
|
309
|
+
return (entry.minSequence === undefined || sequence >= entry.minSequence)
|
|
310
|
+
&& (entry.maxSequence === undefined || sequence <= entry.maxSequence);
|
|
311
|
+
}
|
|
312
|
+
export function verifyFlowRelease(value, options) {
|
|
313
|
+
const release = parseSignedRelease(value);
|
|
314
|
+
const manifest = parseFlowReleaseManifest(release.manifest);
|
|
315
|
+
if (manifest.trustDomain !== options.expectedTrustDomain) {
|
|
316
|
+
fail('FLOW_RELEASE_TRUST_DOMAIN_MISMATCH', 'Flow release trust domain does not match the expected worker trust domain.');
|
|
317
|
+
}
|
|
318
|
+
const policies = options.trustPolicy.filter((entry) => entry.relayUrl === options.expectedRelayUrl && entry.trustDomain === options.expectedTrustDomain);
|
|
319
|
+
if (policies.length === 0)
|
|
320
|
+
fail('UNTRUSTED_FLOW_RELEASE', 'No trust policy matches the exact relay URL and trust domain.');
|
|
321
|
+
for (const policy of policies)
|
|
322
|
+
validatePolicy(policy);
|
|
323
|
+
const policyKeys = new Set();
|
|
324
|
+
for (const policy of policies) {
|
|
325
|
+
if (policyKeys.has(policy.keyId))
|
|
326
|
+
fail('INVALID_FLOW_TRUST_POLICY', `Multiple trust policies match keyId ${policy.keyId}.`);
|
|
327
|
+
policyKeys.add(policy.keyId);
|
|
328
|
+
}
|
|
329
|
+
const message = Buffer.concat([Buffer.from(FLOW_RELEASE_SIGNATURE_PREFIX, 'utf8'), Buffer.from(release.manifest, 'utf8')]);
|
|
330
|
+
let verifiedKeyId;
|
|
331
|
+
let matchingKeyOutsideSequenceRange = false;
|
|
332
|
+
for (const signature of release.signatures) {
|
|
333
|
+
const policy = policies.find((entry) => entry.keyId === signature.keyId);
|
|
334
|
+
if (!policy)
|
|
335
|
+
continue;
|
|
336
|
+
if (!sequenceAllowed(policy, manifest.sequence)) {
|
|
337
|
+
matchingKeyOutsideSequenceRange = true;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const key = createPublicKey({ key: policy.publicKeySpkiPem, format: 'pem', type: 'spki' });
|
|
342
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
343
|
+
fail('INVALID_FLOW_TRUST_POLICY', `Trusted key ${policy.keyId} is not Ed25519.`);
|
|
344
|
+
if (verifySignature(null, message, key, Buffer.from(signature.value, 'base64'))) {
|
|
345
|
+
verifiedKeyId = signature.keyId;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (error instanceof WebFlowError)
|
|
351
|
+
throw error;
|
|
352
|
+
fail('INVALID_FLOW_TRUST_POLICY', `Trusted key ${policy.keyId} is not a valid SPKI PEM Ed25519 key.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!verifiedKeyId && matchingKeyOutsideSequenceRange) {
|
|
356
|
+
fail('FLOW_RELEASE_SEQUENCE_OUT_OF_POLICY', 'Flow release sequence is outside the trusted key policy range.');
|
|
357
|
+
}
|
|
358
|
+
if (!verifiedKeyId)
|
|
359
|
+
fail('INVALID_FLOW_RELEASE_SIGNATURE', 'No trusted Ed25519 signature verifies this flow release.');
|
|
360
|
+
const selected = manifest.flows.find((flow) => flow.name === release.flowName);
|
|
361
|
+
if (!selected)
|
|
362
|
+
fail('FLOW_NOT_IN_RELEASE', `Flow ${release.flowName} is not present in the signed manifest.`);
|
|
363
|
+
verifyArtifact(release.code, selected.code, FLOW_RELEASE_LIMITS.codeBytes, 'code');
|
|
364
|
+
verifyArtifact(release.schema, selected.schema, FLOW_RELEASE_LIMITS.schemaBytes, 'schema');
|
|
365
|
+
verifyArtifact(release.capabilities, selected.capabilities, FLOW_RELEASE_LIMITS.capabilitiesBytes, 'capabilities');
|
|
366
|
+
// Parsing happens only after all three byte strings match their signed descriptors.
|
|
367
|
+
const schema = parseJsonObject(release.schema, 'schema');
|
|
368
|
+
const capabilities = parseJsonObject(release.capabilities, 'capabilities');
|
|
369
|
+
const flowDigest = sha256(`${FLOW_DIGEST_PREFIX}${JSON.stringify([
|
|
370
|
+
selected.name,
|
|
371
|
+
selected.code.sha256,
|
|
372
|
+
selected.schema.sha256,
|
|
373
|
+
selected.capabilities.sha256
|
|
374
|
+
])}`);
|
|
375
|
+
const result = Object.freeze({
|
|
376
|
+
flowName: selected.name,
|
|
377
|
+
code: release.code,
|
|
378
|
+
schema,
|
|
379
|
+
capabilities,
|
|
380
|
+
releaseId: sha256(release.manifest),
|
|
381
|
+
sequence: manifest.sequence,
|
|
382
|
+
version: manifest.version,
|
|
383
|
+
keyId: verifiedKeyId,
|
|
384
|
+
trustDomain: manifest.trustDomain,
|
|
385
|
+
flowDigest
|
|
386
|
+
});
|
|
387
|
+
verifiedValues.add(result);
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
export function isVerifiedRemoteFlow(value) {
|
|
391
|
+
return typeof value === 'object' && value !== null && verifiedValues.has(value);
|
|
392
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const OFFICIAL_FLOW_TRUST_POLICY = Object.freeze([
|
|
2
|
+
{
|
|
3
|
+
"relayUrl": "wss://int-relay.webflowmcp.es/worker",
|
|
4
|
+
"trustDomain": "webflowmcp-staging",
|
|
5
|
+
"keyId": "6bdcc7f3845bfef4c45c0c36b66a08cda60db74f2f16770941027c5e9d0593af",
|
|
6
|
+
"publicKeySpkiPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAdF8bnAlyGF65lQT/YPlQJfKkn5BgFICmw3mfpC3cD+8=\n-----END PUBLIC KEY-----\n",
|
|
7
|
+
"minSequence": 1
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"relayUrl": "wss://relay.webflowmcp.es/worker",
|
|
11
|
+
"trustDomain": "webflowmcp-production",
|
|
12
|
+
"keyId": "6518d0ca28e1b758b6446fb551c6966edc015c3f0043c50cfad10a9390de9932",
|
|
13
|
+
"publicKeySpkiPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAV1bKXnrbI1SLky01lsl0FRSsLC/fYTzDBifla3bqb8s=\n-----END PUBLIC KEY-----\n",
|
|
14
|
+
"minSequence": 1
|
|
15
|
+
}
|
|
16
|
+
].map((entry) => Object.freeze(entry)));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FlowTrustPolicyEntry } from './flow-release.js';
|
|
2
|
+
export { OFFICIAL_FLOW_TRUST_POLICY } from './official-flow-trust.js';
|
|
3
|
+
export declare const FLOW_TRUST_POLICY_PATH_ENV = "WEBFLOW_FLOW_TRUST_POLICY_PATH";
|
|
4
|
+
/**
|
|
5
|
+
* Loads an explicit worker-local trust root for a custom/dev relay. The relay
|
|
6
|
+
* cannot provide this path or any key material over WebSocket.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadFlowTrustPolicy(relayUrl: string, policyPath?: string | undefined): readonly FlowTrustPolicyEntry[];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createPublicKey } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
5
|
+
import { deriveFlowReleaseKeyId, parseStrictJson } from './flow-release.js';
|
|
6
|
+
import { OFFICIAL_FLOW_TRUST_POLICY } from './official-flow-trust.js';
|
|
7
|
+
export { OFFICIAL_FLOW_TRUST_POLICY } from './official-flow-trust.js';
|
|
8
|
+
export const FLOW_TRUST_POLICY_PATH_ENV = 'WEBFLOW_FLOW_TRUST_POLICY_PATH';
|
|
9
|
+
const OFFICIAL_RELAY_HOSTS = new Set(['relay.webflowmcp.es', 'int-relay.webflowmcp.es']);
|
|
10
|
+
const MAX_POLICY_BYTES = 64 * 1024;
|
|
11
|
+
function fail(message, details) {
|
|
12
|
+
throw new WebFlowError('INVALID_FLOW_TRUST_POLICY', message, details);
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function hasExactKeys(value, required, optional = []) {
|
|
18
|
+
const allowed = new Set([...required, ...optional]);
|
|
19
|
+
const keys = Object.keys(value);
|
|
20
|
+
return required.every((key) => Object.hasOwn(value, key)) && keys.every((key) => allowed.has(key));
|
|
21
|
+
}
|
|
22
|
+
function boundedString(value, max = 256) {
|
|
23
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
24
|
+
}
|
|
25
|
+
function parsePolicyEntry(value, relayUrl) {
|
|
26
|
+
if (!isRecord(value) || !hasExactKeys(value, ['relayUrl', 'trustDomain', 'keyId', 'publicKeySpkiPem'], ['minSequence', 'maxSequence'])) {
|
|
27
|
+
fail('Local trust policy entries contain missing or unknown fields.');
|
|
28
|
+
}
|
|
29
|
+
if (value.relayUrl !== relayUrl) {
|
|
30
|
+
fail(`Every local trust policy entry must match the exact configured relay URL ${relayUrl}.`);
|
|
31
|
+
}
|
|
32
|
+
if (!boundedString(value.trustDomain) || !boundedString(value.keyId, 128)
|
|
33
|
+
|| typeof value.publicKeySpkiPem !== 'string' || value.publicKeySpkiPem.length === 0
|
|
34
|
+
|| value.publicKeySpkiPem.length > 16 * 1024 || /[\u0000\u007f]/.test(value.publicKeySpkiPem)) {
|
|
35
|
+
fail('Trust domain, key ID, and public SPKI PEM must be non-empty bounded strings.');
|
|
36
|
+
}
|
|
37
|
+
if (!value.publicKeySpkiPem.includes('-----BEGIN PUBLIC KEY-----') || value.publicKeySpkiPem.includes('PRIVATE KEY')) {
|
|
38
|
+
fail('Local trust policy accepts only public SPKI PEM keys, never private keys.');
|
|
39
|
+
}
|
|
40
|
+
for (const field of ['minSequence', 'maxSequence']) {
|
|
41
|
+
const sequence = value[field];
|
|
42
|
+
if (sequence !== undefined && (!Number.isSafeInteger(sequence) || sequence <= 0)) {
|
|
43
|
+
fail(`${field} must be a positive safe integer.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (typeof value.minSequence === 'number' && typeof value.maxSequence === 'number' && value.minSequence > value.maxSequence) {
|
|
47
|
+
fail('Trust policy sequence range is empty.');
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const key = createPublicKey({ key: value.publicKeySpkiPem, format: 'pem', type: 'spki' });
|
|
51
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
52
|
+
fail(`Trusted key ${value.keyId} is not Ed25519.`);
|
|
53
|
+
if (deriveFlowReleaseKeyId(value.publicKeySpkiPem) !== value.keyId) {
|
|
54
|
+
fail(`Trusted keyId ${value.keyId} does not match its public SPKI SHA-256 digest.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof WebFlowError)
|
|
59
|
+
throw error;
|
|
60
|
+
fail(`Trusted key ${value.keyId} is not a valid public SPKI PEM key.`);
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
relayUrl,
|
|
64
|
+
trustDomain: value.trustDomain,
|
|
65
|
+
keyId: value.keyId,
|
|
66
|
+
publicKeySpkiPem: value.publicKeySpkiPem,
|
|
67
|
+
minSequence: value.minSequence,
|
|
68
|
+
maxSequence: value.maxSequence
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function parsePolicyFile(raw, relayUrl) {
|
|
72
|
+
let value;
|
|
73
|
+
try {
|
|
74
|
+
value = parseStrictJson(raw);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
fail(`Local flow trust policy is not strict JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
|
+
}
|
|
79
|
+
if (!isRecord(value) || !hasExactKeys(value, ['format', 'formatVersion', 'entries'])
|
|
80
|
+
|| value.format !== 'webflowmcp-flow-trust-policy' || value.formatVersion !== 1 || !Array.isArray(value.entries)) {
|
|
81
|
+
fail('Local flow trust policy must use format webflowmcp-flow-trust-policy version 1.');
|
|
82
|
+
}
|
|
83
|
+
const entries = value.entries.map((entry) => parsePolicyEntry(entry, relayUrl));
|
|
84
|
+
const identities = entries.map((entry) => `${entry.trustDomain}\0${entry.keyId}`);
|
|
85
|
+
if (new Set(identities).size !== identities.length)
|
|
86
|
+
fail('Local trust policy contains duplicate trust-domain/key identities.');
|
|
87
|
+
return { format: 'webflowmcp-flow-trust-policy', formatVersion: 1, entries };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Loads an explicit worker-local trust root for a custom/dev relay. The relay
|
|
91
|
+
* cannot provide this path or any key material over WebSocket.
|
|
92
|
+
*/
|
|
93
|
+
export function loadFlowTrustPolicy(relayUrl, policyPath = process.env[FLOW_TRUST_POLICY_PATH_ENV]) {
|
|
94
|
+
if (!policyPath?.trim())
|
|
95
|
+
return OFFICIAL_FLOW_TRUST_POLICY;
|
|
96
|
+
let hostname;
|
|
97
|
+
try {
|
|
98
|
+
hostname = new URL(relayUrl).hostname.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
fail(`Cannot bind a local trust policy to invalid relay URL ${relayUrl}.`);
|
|
102
|
+
}
|
|
103
|
+
if (OFFICIAL_RELAY_HOSTS.has(hostname)) {
|
|
104
|
+
fail(`Local trust-policy overrides are disabled for official relay ${hostname}; its public keys must ship with the worker.`);
|
|
105
|
+
}
|
|
106
|
+
const path = resolve(policyPath.trim());
|
|
107
|
+
let raw;
|
|
108
|
+
let descriptor;
|
|
109
|
+
try {
|
|
110
|
+
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW;
|
|
111
|
+
descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
112
|
+
const metadata = fstatSync(descriptor);
|
|
113
|
+
if (!metadata.isFile())
|
|
114
|
+
fail(`Local trust policy path must be a regular file: ${path}`);
|
|
115
|
+
if (process.platform !== 'win32' && (metadata.mode & 0o022) !== 0) {
|
|
116
|
+
fail(`Local trust policy must not be group- or world-writable: ${path}`);
|
|
117
|
+
}
|
|
118
|
+
if (metadata.size > MAX_POLICY_BYTES)
|
|
119
|
+
fail(`Local trust policy exceeds ${MAX_POLICY_BYTES} bytes.`);
|
|
120
|
+
raw = readFileSync(descriptor, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof WebFlowError)
|
|
124
|
+
throw error;
|
|
125
|
+
fail(`Unable to read local flow trust policy at ${path}: ${error instanceof Error ? error.message : String(error)}`, { path });
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (descriptor !== undefined)
|
|
129
|
+
closeSync(descriptor);
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze(parsePolicyFile(raw, relayUrl).entries);
|
|
132
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface WorkerCredentials {
|
|
2
|
+
version: 2;
|
|
3
|
+
appUrl: string;
|
|
4
|
+
relayUrl: string;
|
|
5
|
+
deviceId: string;
|
|
6
|
+
workerId: string;
|
|
7
|
+
credentialId: string;
|
|
8
|
+
secret: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DeviceIdentity {
|
|
11
|
+
deviceId: string;
|
|
12
|
+
workerId: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function assertSecureRelayUrl(relayUrl: string): void;
|
|
15
|
+
export declare function loadCredentials(): Promise<WorkerCredentials | undefined>;
|
|
16
|
+
export declare function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity>;
|
|
17
|
+
export declare function getWireCredential(credentials: WorkerCredentials): string;
|
|
18
|
+
/** Owner-only permissions (0600): this file holds the worker's live secret. */
|
|
19
|
+
export declare function saveCredentials(credentials: WorkerCredentials): Promise<void>;
|
|
20
|
+
/** Returns false when there was nothing to remove, so callers can tell "already logged out". */
|
|
21
|
+
export declare function clearCredentials(): Promise<boolean>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { ensureDir, pathExists, readJsonFile, writeJsonFile } from './fs.js';
|
|
5
|
+
import { WebFlowError } from './errors.js';
|
|
6
|
+
import { getCredentialsPath, getDeviceIdentityPath } from './paths.js';
|
|
7
|
+
export function assertSecureRelayUrl(relayUrl) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(relayUrl);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new WebFlowError('INVALID_RELAY_URL', `Invalid relay URL: ${relayUrl}`);
|
|
14
|
+
}
|
|
15
|
+
if (parsed.protocol !== 'wss:') {
|
|
16
|
+
throw new WebFlowError('INSECURE_RELAY_URL', 'The relay URL must use wss://. Refusing to expose worker credentials or accept commands over an insecure connection.');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function isWorkerCredentials(value) {
|
|
20
|
+
if (typeof value !== 'object' || value === null)
|
|
21
|
+
return false;
|
|
22
|
+
const record = value;
|
|
23
|
+
return record.version === 2
|
|
24
|
+
&& typeof record.appUrl === 'string'
|
|
25
|
+
&& typeof record.relayUrl === 'string'
|
|
26
|
+
&& typeof record.deviceId === 'string'
|
|
27
|
+
&& typeof record.workerId === 'string'
|
|
28
|
+
&& typeof record.credentialId === 'string'
|
|
29
|
+
&& typeof record.secret === 'string'
|
|
30
|
+
&& /^[a-f0-9]{64}$/.test(record.secret);
|
|
31
|
+
}
|
|
32
|
+
export async function loadCredentials() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readJsonFile(getCredentialsPath());
|
|
35
|
+
if (isWorkerCredentials(raw))
|
|
36
|
+
return raw;
|
|
37
|
+
if (typeof raw === 'object' && raw !== null && ('token' in raw || raw.version === 1)) {
|
|
38
|
+
throw new WebFlowError('CREDENTIALS_REPAIR_REQUIRED', 'Saved credentials use the unsupported v1 format. Run "webflow login" to pair this device again.');
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof WebFlowError)
|
|
44
|
+
throw error;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isDeviceIdentity(value) {
|
|
49
|
+
if (typeof value !== 'object' || value === null)
|
|
50
|
+
return false;
|
|
51
|
+
const record = value;
|
|
52
|
+
return typeof record.deviceId === 'string' && record.deviceId.length > 0
|
|
53
|
+
&& typeof record.workerId === 'string' && record.workerId.length > 0;
|
|
54
|
+
}
|
|
55
|
+
export async function loadOrCreateDeviceIdentity() {
|
|
56
|
+
const path = getDeviceIdentityPath();
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readJsonFile(path);
|
|
59
|
+
if (isDeviceIdentity(raw))
|
|
60
|
+
return raw;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Missing or malformed identity: replace it with a fresh stable identity.
|
|
64
|
+
}
|
|
65
|
+
let credentials;
|
|
66
|
+
try {
|
|
67
|
+
credentials = await loadCredentials();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!(error instanceof WebFlowError) || error.code !== 'CREDENTIALS_REPAIR_REQUIRED')
|
|
71
|
+
throw error;
|
|
72
|
+
// `webflow login` is the repair path, so a legacy credential must not block it.
|
|
73
|
+
}
|
|
74
|
+
const identity = credentials
|
|
75
|
+
? { deviceId: credentials.deviceId, workerId: credentials.workerId }
|
|
76
|
+
: { deviceId: randomUUID(), workerId: randomUUID() };
|
|
77
|
+
await ensureDir(dirname(path));
|
|
78
|
+
await writeJsonFile(path, identity);
|
|
79
|
+
await chmod(path, 0o600);
|
|
80
|
+
return identity;
|
|
81
|
+
}
|
|
82
|
+
export function getWireCredential(credentials) {
|
|
83
|
+
return `wkr_${credentials.credentialId}.${credentials.secret}`;
|
|
84
|
+
}
|
|
85
|
+
/** Owner-only permissions (0600): this file holds the worker's live secret. */
|
|
86
|
+
export async function saveCredentials(credentials) {
|
|
87
|
+
const path = getCredentialsPath();
|
|
88
|
+
await ensureDir(dirname(path));
|
|
89
|
+
await writeJsonFile(path, credentials);
|
|
90
|
+
await chmod(path, 0o600);
|
|
91
|
+
}
|
|
92
|
+
/** Returns false when there was nothing to remove, so callers can tell "already logged out". */
|
|
93
|
+
export async function clearCredentials() {
|
|
94
|
+
const path = getCredentialsPath();
|
|
95
|
+
if (!(await pathExists(path)))
|
|
96
|
+
return false;
|
|
97
|
+
await rm(path, { force: true });
|
|
98
|
+
return true;
|
|
99
|
+
}
|