blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,499 +1,499 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lookup as dnsLookup } from "node:dns/promises";
|
|
3
|
-
import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
-
import { request as httpsRequest } from "node:https";
|
|
5
|
-
import { isIP } from "node:net";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
assertTrustedIdentity, loadTrust, signEnvelope, verifyEnvelope
|
|
9
|
-
} from "./authentication.js";
|
|
10
|
-
import { buildCatalog } from "./catalog.js";
|
|
11
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
12
|
-
import {
|
|
13
|
-
buildHttpsSnapshot, fetchHttpsSnapshot, importHttpsSnapshot, resolveHttpsEndpoint
|
|
14
|
-
} from "./https-transport.js";
|
|
15
|
-
import {
|
|
16
|
-
httpsObjectUrl, putHttpsSnapshot, validateHttpsObjectBase
|
|
17
|
-
} from "./object-transport.js";
|
|
18
|
-
import { projectStateDir, statePathIsScanExcluded } from "./paths.js";
|
|
19
|
-
|
|
20
|
-
const SCHEMA = "agentspine.feed/v1";
|
|
21
|
-
const STATE_SCHEMA = "agentspine.feed-state/v1";
|
|
22
|
-
const CONFIRMATION = "local-share-confirmed";
|
|
23
|
-
const MAX_ENTRIES = 256;
|
|
24
|
-
const MAX_FEED_BYTES = 256 * 1024;
|
|
25
|
-
const MAX_STATE_BYTES = 4 * 1024 * 1024;
|
|
26
|
-
const MAX_TOKEN_BYTES = 8192;
|
|
27
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
28
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
29
|
-
const ETAG_RE = /^"[^"\r\n]{1,128}"$/;
|
|
30
|
-
const TOKEN_ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
31
|
-
|
|
32
|
-
function canonical(value) {
|
|
33
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
34
|
-
if (value && typeof value === "object") {
|
|
35
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
36
|
-
}
|
|
37
|
-
return JSON.stringify(value);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function digest(value) {
|
|
41
|
-
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function exactKeys(value, expected) {
|
|
45
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
46
|
-
const actual = Object.keys(value).sort();
|
|
47
|
-
const wanted = [...expected].sort();
|
|
48
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function validDate(value) {
|
|
52
|
-
return typeof value === "string" && Number.isFinite(new Date(value).getTime());
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isoDate(value, field = "date") {
|
|
56
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
57
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
58
|
-
return parsed.toISOString();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function bearerToken(tokenEnv, environment) {
|
|
62
|
-
if (tokenEnv === null || tokenEnv === undefined) return null;
|
|
63
|
-
if (!TOKEN_ENV_RE.test(tokenEnv)) throw new Error("tokenEnv must be an uppercase environment variable name");
|
|
64
|
-
const value = environment[tokenEnv];
|
|
65
|
-
if (typeof value !== "string" || !value) throw new Error(`HTTPS feed token environment variable is missing: ${tokenEnv}`);
|
|
66
|
-
if (Buffer.byteLength(value) > MAX_TOKEN_BYTES || /[\0-\x1f\x7f]/.test(value)) {
|
|
67
|
-
throw new Error("HTTPS feed bearer token is invalid");
|
|
68
|
-
}
|
|
69
|
-
return value;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function timeout(value) {
|
|
73
|
-
if (!Number.isInteger(value) || value < 1000 || value > 30000) {
|
|
74
|
-
throw new Error("HTTPS feed timeout must be between 1000 and 30000 milliseconds");
|
|
75
|
-
}
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function within(promise, milliseconds, message) {
|
|
80
|
-
let timer;
|
|
81
|
-
try {
|
|
82
|
-
return await Promise.race([
|
|
83
|
-
promise,
|
|
84
|
-
new Promise((_resolve, reject) => {
|
|
85
|
-
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
86
|
-
})
|
|
87
|
-
]);
|
|
88
|
-
} finally {
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function httpsFeedUrl(baseUrl, feedId) {
|
|
94
|
-
if (!ID_RE.test(feedId || "")) throw new Error("feedId must be a stable, whitespace-free identifier");
|
|
95
|
-
const endpoint = validateHttpsObjectBase(baseUrl);
|
|
96
|
-
const name = createHash("sha256").update(feedId).digest("hex");
|
|
97
|
-
endpoint.pathname = `${endpoint.pathname}/feeds/${name}.json`;
|
|
98
|
-
return endpoint.toString();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function validateEntry(entry) {
|
|
102
|
-
if (!exactKeys(entry, [
|
|
103
|
-
"sequence", "snapshotId", "snapshotDigest", "previousDigest", "publishedAt", "authority", "digest"
|
|
104
|
-
]) || !Number.isSafeInteger(entry.sequence) || entry.sequence < 1
|
|
105
|
-
|| !ID_RE.test(entry.snapshotId || "") || !DIGEST_RE.test(entry.snapshotDigest || "")
|
|
106
|
-
|| !(entry.previousDigest === null || DIGEST_RE.test(entry.previousDigest))
|
|
107
|
-
|| !validDate(entry.publishedAt) || entry.authority !== "context-only"
|
|
108
|
-
|| !DIGEST_RE.test(entry.digest || "")) {
|
|
109
|
-
throw new Error("HTTPS feed entry is invalid");
|
|
110
|
-
}
|
|
111
|
-
const { digest: _digest, ...body } = entry;
|
|
112
|
-
if (digest(body) !== entry.digest) throw new Error("HTTPS feed entry failed its integrity check");
|
|
113
|
-
return entry;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function validateHttpsFeed(envelope) {
|
|
117
|
-
const verified = verifyEnvelope(envelope, "manifest");
|
|
118
|
-
const feed = verified.payload;
|
|
119
|
-
if (!exactKeys(feed, [
|
|
120
|
-
"schema", "feedId", "scopeId", "adapterId", "sequence", "entries", "authority", "digest"
|
|
121
|
-
]) || feed.schema !== SCHEMA || !ID_RE.test(feed.feedId || "")
|
|
122
|
-
|| !ID_RE.test(feed.scopeId || "") || !ID_RE.test(feed.adapterId || "")
|
|
123
|
-
|| !Number.isSafeInteger(feed.sequence) || feed.sequence < 1
|
|
124
|
-
|| !Array.isArray(feed.entries) || feed.entries.length < 1 || feed.entries.length > MAX_ENTRIES
|
|
125
|
-
|| feed.authority !== "context-only" || !DIGEST_RE.test(feed.digest || "")) {
|
|
126
|
-
throw new Error("HTTPS feed is invalid");
|
|
127
|
-
}
|
|
128
|
-
if (Buffer.byteLength(JSON.stringify(envelope)) > MAX_FEED_BYTES) throw new Error("HTTPS feed exceeds 256 KiB");
|
|
129
|
-
const { digest: _digest, ...body } = feed;
|
|
130
|
-
if (digest(body) !== feed.digest) throw new Error("HTTPS feed failed its integrity check");
|
|
131
|
-
let previous = null;
|
|
132
|
-
for (const entry of feed.entries) {
|
|
133
|
-
validateEntry(entry);
|
|
134
|
-
if (previous) {
|
|
135
|
-
if (entry.sequence !== previous.sequence + 1 || entry.previousDigest !== previous.digest) {
|
|
136
|
-
throw new Error("HTTPS feed hash chain is broken");
|
|
137
|
-
}
|
|
138
|
-
} else if (entry.sequence === 1 && entry.previousDigest !== null) {
|
|
139
|
-
throw new Error("HTTPS feed genesis entry cannot have a predecessor");
|
|
140
|
-
} else if (entry.sequence > 1 && entry.previousDigest === null) {
|
|
141
|
-
throw new Error("truncated HTTPS feed window must retain its predecessor digest");
|
|
142
|
-
}
|
|
143
|
-
previous = entry;
|
|
144
|
-
}
|
|
145
|
-
if (previous.sequence !== feed.sequence) throw new Error("HTTPS feed sequence does not match its latest entry");
|
|
146
|
-
return { envelope, feed, authentication: verified.authentication, publicIdentity: verified.publicIdentity };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function nextFeedBody({ current, feedId, snapshot, publishedAt }) {
|
|
150
|
-
if (current && (current.feedId !== feedId || current.scopeId !== snapshot.scopeId
|
|
151
|
-
|| current.adapterId !== snapshot.adapterId)) {
|
|
152
|
-
throw new Error("HTTPS feed identity does not match the snapshot");
|
|
153
|
-
}
|
|
154
|
-
const latest = current?.entries.at(-1) || null;
|
|
155
|
-
const entryBody = {
|
|
156
|
-
sequence: (latest?.sequence || 0) + 1,
|
|
157
|
-
snapshotId: snapshot.snapshotId,
|
|
158
|
-
snapshotDigest: snapshot.digest,
|
|
159
|
-
previousDigest: latest?.digest || null,
|
|
160
|
-
publishedAt,
|
|
161
|
-
authority: "context-only"
|
|
162
|
-
};
|
|
163
|
-
const entry = { ...entryBody, digest: digest(entryBody) };
|
|
164
|
-
const entries = [...(current?.entries || []), entry].slice(-MAX_ENTRIES);
|
|
165
|
-
const body = {
|
|
166
|
-
schema: SCHEMA, feedId, scopeId: snapshot.scopeId, adapterId: snapshot.adapterId,
|
|
167
|
-
sequence: entry.sequence, entries, authority: "context-only"
|
|
168
|
-
};
|
|
169
|
-
return { ...body, digest: digest(body) };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function resolvedRequest({
|
|
173
|
-
url, method, headers, body = null, timeoutMs, allowPrivateNetwork, lookup, request, accepted
|
|
174
|
-
}) {
|
|
175
|
-
const resolved = await within(
|
|
176
|
-
resolveHttpsEndpoint(url, { allowPrivateNetwork, lookup }), timeoutMs,
|
|
177
|
-
"HTTPS feed DNS resolution timed out"
|
|
178
|
-
);
|
|
179
|
-
const selected = resolved.addresses[0];
|
|
180
|
-
return new Promise((resolvePromise, rejectPromise) => {
|
|
181
|
-
let deadline;
|
|
182
|
-
let settled = false;
|
|
183
|
-
const finish = (operation, value) => {
|
|
184
|
-
if (settled) return;
|
|
185
|
-
settled = true;
|
|
186
|
-
clearTimeout(deadline);
|
|
187
|
-
operation(value);
|
|
188
|
-
};
|
|
189
|
-
const options = {
|
|
190
|
-
protocol: "https:", hostname: resolved.lookupHostname,
|
|
191
|
-
port: resolved.endpoint.port || 443, path: resolved.endpoint.pathname,
|
|
192
|
-
method, headers, agent: false, autoSelectFamily: false,
|
|
193
|
-
servername: isIP(resolved.lookupHostname) ? undefined : resolved.lookupHostname,
|
|
194
|
-
lookup: (_hostname, lookupOptions, callback) => {
|
|
195
|
-
const family = typeof lookupOptions === "object" && lookupOptions.family
|
|
196
|
-
? Number(lookupOptions.family) : 0;
|
|
197
|
-
const usable = family ? resolved.addresses.find((item) => item.family === family) : selected;
|
|
198
|
-
if (!usable) return callback(new Error("no vetted DNS address matches the requested family"));
|
|
199
|
-
return callback(null, usable.address, usable.family);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
const active = request(options, (response) => {
|
|
203
|
-
const status = Number(response.statusCode || 0);
|
|
204
|
-
if (status >= 300 && status < 400 && status !== 304) {
|
|
205
|
-
response.resume();
|
|
206
|
-
finish(rejectPromise, new Error("HTTPS feed redirects are not followed"));
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const chunks = [];
|
|
210
|
-
let bytes = 0;
|
|
211
|
-
response.on("data", (chunk) => {
|
|
212
|
-
bytes += chunk.length;
|
|
213
|
-
if (bytes > MAX_FEED_BYTES) active.destroy(new Error("HTTPS feed exceeds 256 KiB"));
|
|
214
|
-
else chunks.push(chunk);
|
|
215
|
-
});
|
|
216
|
-
response.on("end", () => {
|
|
217
|
-
if (!accepted.includes(status)) {
|
|
218
|
-
finish(rejectPromise, new Error(`HTTPS feed request failed with status ${status}`));
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
finish(resolvePromise, { status, headers: response.headers, body: Buffer.concat(chunks) });
|
|
222
|
-
});
|
|
223
|
-
response.on("error", (error) => finish(rejectPromise, error));
|
|
224
|
-
});
|
|
225
|
-
active.setTimeout(timeoutMs, () => active.destroy(new Error("HTTPS feed request timed out")));
|
|
226
|
-
deadline = setTimeout(() => active.destroy(new Error("HTTPS feed request timed out")), timeoutMs);
|
|
227
|
-
active.on("error", (error) => finish(rejectPromise, error));
|
|
228
|
-
active.end(body);
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export async function fetchHttpsFeed({
|
|
233
|
-
baseUrl, feedId, tokenEnv = null, timeoutMs = 10000, allowPrivateNetwork = false,
|
|
234
|
-
confirmation = null, environment = process.env, lookup = dnsLookup, request = httpsRequest,
|
|
235
|
-
allowMissing = false
|
|
236
|
-
}) {
|
|
237
|
-
timeout(timeoutMs);
|
|
238
|
-
if (allowPrivateNetwork && confirmation !== CONFIRMATION) {
|
|
239
|
-
throw new Error("private-network HTTPS feed access requires explicit local owner confirmation");
|
|
240
|
-
}
|
|
241
|
-
const token = bearerToken(tokenEnv, environment);
|
|
242
|
-
const url = httpsFeedUrl(baseUrl, feedId);
|
|
243
|
-
const headers = {
|
|
244
|
-
accept: "application/vnd.agentspine.feed+json", "accept-encoding": "identity",
|
|
245
|
-
"user-agent": "AgentSpine/0.1 HTTPS feed transport"
|
|
246
|
-
};
|
|
247
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
248
|
-
const response = await resolvedRequest({
|
|
249
|
-
url, method: "GET", headers, timeoutMs, allowPrivateNetwork, lookup, request,
|
|
250
|
-
accepted: allowMissing ? [200, 404] : [200]
|
|
251
|
-
});
|
|
252
|
-
if (response.status === 404) return { missing: true, url, authenticatedRequest: Boolean(token) };
|
|
253
|
-
const contentType = String(response.headers["content-type"] || "").toLowerCase();
|
|
254
|
-
if (!/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/.test(contentType)) {
|
|
255
|
-
throw new Error("HTTPS feed response must use an application/json content type");
|
|
256
|
-
}
|
|
257
|
-
if (String(response.headers["content-encoding"] || "identity").toLowerCase() !== "identity") {
|
|
258
|
-
throw new Error("compressed HTTPS feeds are rejected");
|
|
259
|
-
}
|
|
260
|
-
const etag = String(response.headers.etag || "");
|
|
261
|
-
if (!ETAG_RE.test(etag)) throw new Error("HTTPS feed response requires one strong ETag");
|
|
262
|
-
let envelope;
|
|
263
|
-
try { envelope = JSON.parse(response.body.toString("utf8")); } catch { throw new Error("HTTPS feed response is not valid JSON"); }
|
|
264
|
-
const validated = validateHttpsFeed(envelope);
|
|
265
|
-
if (validated.feed.feedId !== feedId) throw new Error("HTTPS feed resource has the wrong feedId");
|
|
266
|
-
return { ...validated, missing: false, etag, url, authenticatedRequest: Boolean(token) };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function putFeed({ baseUrl, feedId, envelope, etag, options }) {
|
|
270
|
-
const token = bearerToken(options.tokenEnv, options.environment);
|
|
271
|
-
const body = Buffer.from(JSON.stringify(envelope));
|
|
272
|
-
if (body.length > MAX_FEED_BYTES) throw new Error("HTTPS feed exceeds 256 KiB");
|
|
273
|
-
const headers = {
|
|
274
|
-
accept: "application/json", "accept-encoding": "identity",
|
|
275
|
-
"content-length": String(body.length), "content-type": "application/vnd.agentspine.feed+json",
|
|
276
|
-
"user-agent": "AgentSpine/0.1 HTTPS feed transport"
|
|
277
|
-
};
|
|
278
|
-
if (etag) headers["if-match"] = etag;
|
|
279
|
-
else headers["if-none-match"] = "*";
|
|
280
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
281
|
-
try {
|
|
282
|
-
return await resolvedRequest({
|
|
283
|
-
url: httpsFeedUrl(baseUrl, feedId), method: "PUT", headers, body,
|
|
284
|
-
timeoutMs: options.timeoutMs, allowPrivateNetwork: options.allowPrivateNetwork,
|
|
285
|
-
lookup: options.lookup, request: options.request, accepted: etag ? [200, 204] : [201, 204]
|
|
286
|
-
});
|
|
287
|
-
} catch (error) {
|
|
288
|
-
if (/status 412/.test(error.message)) throw new Error("HTTPS feed changed concurrently; fetch and retry");
|
|
289
|
-
throw error;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export async function publishHttpsFeed({
|
|
294
|
-
root = process.cwd(), directory, baseUrl, feedId, signerId,
|
|
295
|
-
snapshotId = `snapshot:${randomUUID()}`, now = new Date(), tokenEnv = null,
|
|
296
|
-
timeoutMs = 10000, allowPrivateNetwork = false, confirmation = null,
|
|
297
|
-
environment = process.env, lookup = dnsLookup, request = httpsRequest
|
|
298
|
-
}) {
|
|
299
|
-
if (confirmation !== CONFIRMATION) throw new Error("HTTPS feed publishing requires explicit local owner confirmation");
|
|
300
|
-
if (!ID_RE.test(signerId || "")) throw new Error("signerId is required for HTTPS feed publishing");
|
|
301
|
-
timeout(timeoutMs);
|
|
302
|
-
const publishedAt = isoDate(now, "now");
|
|
303
|
-
const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
|
|
304
|
-
const transportOptions = {
|
|
305
|
-
tokenEnv, timeoutMs, allowPrivateNetwork, confirmation, environment, lookup, request
|
|
306
|
-
};
|
|
307
|
-
const object = await putHttpsSnapshot({ baseUrl, snapshot, ...transportOptions });
|
|
308
|
-
const current = await fetchHttpsFeed({ baseUrl, feedId, allowMissing: true, ...transportOptions });
|
|
309
|
-
if (!current.missing && current.authentication.signerId !== signerId) {
|
|
310
|
-
throw new Error("HTTPS feed signer cannot change during publication");
|
|
311
|
-
}
|
|
312
|
-
const feed = nextFeedBody({ current: current.feed, feedId, snapshot, publishedAt });
|
|
313
|
-
const envelope = await signEnvelope({ root, signerId, kind: "manifest", payload: feed, now });
|
|
314
|
-
if (!current.missing && current.publicIdentity.keyId !== envelope.signer.keyId) {
|
|
315
|
-
throw new Error("HTTPS feed signing key cannot change; use a new feedId after key rotation");
|
|
316
|
-
}
|
|
317
|
-
await putFeed({ baseUrl, feedId, envelope, etag: current.etag || null, options: transportOptions });
|
|
318
|
-
const verified = await fetchHttpsFeed({ baseUrl, feedId, ...transportOptions });
|
|
319
|
-
if (verified.feed.digest !== feed.digest || verified.authentication.signerId !== signerId) {
|
|
320
|
-
throw new Error("HTTPS feed read-back does not match the published chain tip");
|
|
321
|
-
}
|
|
322
|
-
return {
|
|
323
|
-
createdFeed: current.missing, sequence: feed.sequence, feedId, feedDigest: feed.digest,
|
|
324
|
-
entryDigest: feed.entries.at(-1).digest, snapshotId: snapshot.snapshotId,
|
|
325
|
-
snapshotDigest: snapshot.digest, objectUrl: object.objectUrl, feedUrl: verified.url,
|
|
326
|
-
objectCreated: object.created, verified: true, authenticatedWrite: Boolean(bearerToken(tokenEnv, environment)),
|
|
327
|
-
authority: "context-only"
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function emptyState(root) {
|
|
332
|
-
return { schema: STATE_SCHEMA, root, feeds: [], history: [] };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function validateReceipt(record) {
|
|
336
|
-
return exactKeys(record, [
|
|
337
|
-
"feedId", "scopeId", "adapterId", "sequence", "entryDigest", "snapshotDigest",
|
|
338
|
-
"signerId", "keyId", "observedAt", "authority"
|
|
339
|
-
]) && ID_RE.test(record.feedId || "") && ID_RE.test(record.scopeId || "")
|
|
340
|
-
&& ID_RE.test(record.adapterId || "") && Number.isSafeInteger(record.sequence) && record.sequence >= 1
|
|
341
|
-
&& DIGEST_RE.test(record.entryDigest || "") && DIGEST_RE.test(record.snapshotDigest || "")
|
|
342
|
-
&& ID_RE.test(record.signerId || "") && ID_RE.test(record.keyId || "")
|
|
343
|
-
&& validDate(record.observedAt) && record.authority === "context-only";
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function validateState(state, root) {
|
|
347
|
-
if (!exactKeys(state, ["schema", "root", "feeds", "history"]) || state.schema !== STATE_SCHEMA
|
|
348
|
-
|| state.root !== root || !Array.isArray(state.feeds) || !Array.isArray(state.history)
|
|
349
|
-
|| state.feeds.some((item) => !validateReceipt(item)) || state.history.some((item) => !validateReceipt(item))) {
|
|
350
|
-
throw new Error("HTTPS feed state is invalid");
|
|
351
|
-
}
|
|
352
|
-
if (new Set(state.feeds.map((item) => item.feedId)).size !== state.feeds.length) {
|
|
353
|
-
throw new Error("HTTPS feed state contains duplicate feeds");
|
|
354
|
-
}
|
|
355
|
-
return state;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function feedStatePaths(root) {
|
|
359
|
-
const catalog = await buildCatalog(root);
|
|
360
|
-
const directory = await projectStateDir(catalog.root);
|
|
361
|
-
if (!statePathIsScanExcluded(catalog, directory)) {
|
|
362
|
-
throw new Error("HTTPS feed state must remain outside the scanned project or in an excluded home-state root");
|
|
363
|
-
}
|
|
364
|
-
return { root: catalog.root, path: join(directory, "feed-state.json") };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function readState(path, root) {
|
|
368
|
-
try {
|
|
369
|
-
const metadata = await stat(path);
|
|
370
|
-
if (metadata.size > MAX_STATE_BYTES) throw new Error("HTTPS feed state exceeds 4 MiB");
|
|
371
|
-
return validateState(JSON.parse(await readFile(path, "utf8")), root);
|
|
372
|
-
} catch (error) {
|
|
373
|
-
if (error.code !== "ENOENT") throw error;
|
|
374
|
-
return emptyState(root);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async function acquireLock(path) {
|
|
379
|
-
const lockPath = `${path}.lock`;
|
|
380
|
-
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
381
|
-
try { return { handle: await open(lockPath, "wx", 0o600), lockPath }; } catch (error) {
|
|
382
|
-
if (!isFileLockContention(error)) throw error;
|
|
383
|
-
try {
|
|
384
|
-
const metadata = await stat(lockPath);
|
|
385
|
-
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
386
|
-
} catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
|
|
387
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
throw new Error("HTTPS feed state is busy; retry shortly");
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async function saveState(path, state) {
|
|
394
|
-
validateState(state, state.root);
|
|
395
|
-
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
396
|
-
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("HTTPS feed state exceeds 4 MiB");
|
|
397
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
398
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
399
|
-
await replaceFileWithRetry(temporary, path);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function assessFeed(feed, receipt) {
|
|
403
|
-
const latest = feed.entries.at(-1);
|
|
404
|
-
if (!receipt) return { latest, unchanged: false };
|
|
405
|
-
if (receipt.scopeId !== feed.scopeId || receipt.adapterId !== feed.adapterId) {
|
|
406
|
-
throw new Error("HTTPS feed scope or adapter changed");
|
|
407
|
-
}
|
|
408
|
-
if (latest.sequence < receipt.sequence) throw new Error("HTTPS feed rollback detected");
|
|
409
|
-
if (latest.sequence === receipt.sequence) {
|
|
410
|
-
if (latest.digest !== receipt.entryDigest || latest.snapshotDigest !== receipt.snapshotDigest) {
|
|
411
|
-
throw new Error("HTTPS feed equivocation detected at an observed sequence");
|
|
412
|
-
}
|
|
413
|
-
return { latest, unchanged: true };
|
|
414
|
-
}
|
|
415
|
-
const anchor = feed.entries.findIndex((entry) => (
|
|
416
|
-
entry.sequence === receipt.sequence && entry.digest === receipt.entryDigest
|
|
417
|
-
));
|
|
418
|
-
if (anchor < 0) throw new Error("HTTPS feed continuity window no longer contains the last observed entry");
|
|
419
|
-
return { latest, unchanged: false };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export async function loadHttpsFeedState(root = process.cwd()) {
|
|
423
|
-
const paths = await feedStatePaths(root);
|
|
424
|
-
return { state: await readState(paths.path, paths.root), statePath: paths.path };
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
export async function inspectHttpsFeedState(root = process.cwd()) {
|
|
428
|
-
const paths = await feedStatePaths(root);
|
|
429
|
-
try {
|
|
430
|
-
return { state: await readState(paths.path, paths.root), statePath: paths.path, error: null };
|
|
431
|
-
} catch (error) {
|
|
432
|
-
return { state: emptyState(paths.root), statePath: paths.path, error: error.message };
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
export async function pullHttpsFeed({
|
|
437
|
-
root = process.cwd(), baseUrl, feedId, now = new Date(), tokenEnv = null,
|
|
438
|
-
timeoutMs = 10000, allowPrivateNetwork = false, confirmation = null,
|
|
439
|
-
environment = process.env, lookup = dnsLookup, request = httpsRequest
|
|
440
|
-
}) {
|
|
441
|
-
const observedAt = isoDate(now, "now");
|
|
442
|
-
const options = {
|
|
443
|
-
baseUrl, feedId, tokenEnv, timeoutMs, allowPrivateNetwork,
|
|
444
|
-
confirmation, environment, lookup, request
|
|
445
|
-
};
|
|
446
|
-
const remote = await fetchHttpsFeed(options);
|
|
447
|
-
const { trust } = await loadTrust(root);
|
|
448
|
-
assertTrustedIdentity(remote.publicIdentity, trust);
|
|
449
|
-
const paths = await feedStatePaths(root);
|
|
450
|
-
const { handle, lockPath } = await acquireLock(paths.path);
|
|
451
|
-
try {
|
|
452
|
-
const state = await readState(paths.path, paths.root);
|
|
453
|
-
const existing = state.feeds.find((item) => item.feedId === feedId) || null;
|
|
454
|
-
if (existing && (existing.signerId !== remote.publicIdentity.signerId
|
|
455
|
-
|| existing.keyId !== remote.publicIdentity.keyId)) {
|
|
456
|
-
throw new Error("HTTPS feed signing identity changed; use a new feedId after key rotation");
|
|
457
|
-
}
|
|
458
|
-
const assessment = assessFeed(remote.feed, existing);
|
|
459
|
-
if (assessment.unchanged) {
|
|
460
|
-
return {
|
|
461
|
-
changed: false, feedId, sequence: existing.sequence,
|
|
462
|
-
snapshotDigest: existing.snapshotDigest, statePath: paths.path,
|
|
463
|
-
authenticatedRequest: remote.authenticatedRequest, authority: "context-only"
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
const objectUrl = httpsObjectUrl(baseUrl, assessment.latest.snapshotDigest);
|
|
467
|
-
const fetched = await fetchHttpsSnapshot({
|
|
468
|
-
url: objectUrl, tokenEnv, timeoutMs, allowPrivateNetwork,
|
|
469
|
-
confirmation, environment, lookup, request
|
|
470
|
-
});
|
|
471
|
-
if (fetched.snapshot.digest !== assessment.latest.snapshotDigest
|
|
472
|
-
|| fetched.snapshot.snapshotId !== assessment.latest.snapshotId
|
|
473
|
-
|| fetched.snapshot.scopeId !== remote.feed.scopeId
|
|
474
|
-
|| fetched.snapshot.adapterId !== remote.feed.adapterId) {
|
|
475
|
-
throw new Error("HTTPS feed snapshot does not match its signed entry");
|
|
476
|
-
}
|
|
477
|
-
const imported = await importHttpsSnapshot({ root: paths.root, snapshot: fetched.snapshot, now });
|
|
478
|
-
const receipt = {
|
|
479
|
-
feedId, scopeId: remote.feed.scopeId, adapterId: remote.feed.adapterId,
|
|
480
|
-
sequence: assessment.latest.sequence, entryDigest: assessment.latest.digest,
|
|
481
|
-
snapshotDigest: assessment.latest.snapshotDigest,
|
|
482
|
-
signerId: remote.publicIdentity.signerId, keyId: remote.publicIdentity.keyId,
|
|
483
|
-
observedAt, authority: "context-only"
|
|
484
|
-
};
|
|
485
|
-
if (existing) state.history.push(existing);
|
|
486
|
-
state.feeds = [...state.feeds.filter((item) => item.feedId !== feedId), receipt]
|
|
487
|
-
.sort((a, b) => a.feedId.localeCompare(b.feedId));
|
|
488
|
-
await saveState(paths.path, state);
|
|
489
|
-
return {
|
|
490
|
-
...imported, changed: true, feedId, sequence: receipt.sequence,
|
|
491
|
-
entryDigest: receipt.entryDigest, snapshotDigest: receipt.snapshotDigest,
|
|
492
|
-
feedUrl: remote.url, objectUrl, statePath: paths.path,
|
|
493
|
-
authenticatedRequest: remote.authenticatedRequest, authority: "context-only"
|
|
494
|
-
};
|
|
495
|
-
} finally {
|
|
496
|
-
await handle.close();
|
|
497
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
498
|
-
}
|
|
499
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
3
|
+
import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
assertTrustedIdentity, loadTrust, signEnvelope, verifyEnvelope
|
|
9
|
+
} from "./authentication.js";
|
|
10
|
+
import { buildCatalog } from "./catalog.js";
|
|
11
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
12
|
+
import {
|
|
13
|
+
buildHttpsSnapshot, fetchHttpsSnapshot, importHttpsSnapshot, resolveHttpsEndpoint
|
|
14
|
+
} from "./https-transport.js";
|
|
15
|
+
import {
|
|
16
|
+
httpsObjectUrl, putHttpsSnapshot, validateHttpsObjectBase
|
|
17
|
+
} from "./object-transport.js";
|
|
18
|
+
import { projectStateDir, statePathIsScanExcluded } from "./paths.js";
|
|
19
|
+
|
|
20
|
+
const SCHEMA = "agentspine.feed/v1";
|
|
21
|
+
const STATE_SCHEMA = "agentspine.feed-state/v1";
|
|
22
|
+
const CONFIRMATION = "local-share-confirmed";
|
|
23
|
+
const MAX_ENTRIES = 256;
|
|
24
|
+
const MAX_FEED_BYTES = 256 * 1024;
|
|
25
|
+
const MAX_STATE_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
const MAX_TOKEN_BYTES = 8192;
|
|
27
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
28
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
29
|
+
const ETAG_RE = /^"[^"\r\n]{1,128}"$/;
|
|
30
|
+
const TOKEN_ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
31
|
+
|
|
32
|
+
function canonical(value) {
|
|
33
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function digest(value) {
|
|
41
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function exactKeys(value, expected) {
|
|
45
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
46
|
+
const actual = Object.keys(value).sort();
|
|
47
|
+
const wanted = [...expected].sort();
|
|
48
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validDate(value) {
|
|
52
|
+
return typeof value === "string" && Number.isFinite(new Date(value).getTime());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isoDate(value, field = "date") {
|
|
56
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
57
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
58
|
+
return parsed.toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bearerToken(tokenEnv, environment) {
|
|
62
|
+
if (tokenEnv === null || tokenEnv === undefined) return null;
|
|
63
|
+
if (!TOKEN_ENV_RE.test(tokenEnv)) throw new Error("tokenEnv must be an uppercase environment variable name");
|
|
64
|
+
const value = environment[tokenEnv];
|
|
65
|
+
if (typeof value !== "string" || !value) throw new Error(`HTTPS feed token environment variable is missing: ${tokenEnv}`);
|
|
66
|
+
if (Buffer.byteLength(value) > MAX_TOKEN_BYTES || /[\0-\x1f\x7f]/.test(value)) {
|
|
67
|
+
throw new Error("HTTPS feed bearer token is invalid");
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function timeout(value) {
|
|
73
|
+
if (!Number.isInteger(value) || value < 1000 || value > 30000) {
|
|
74
|
+
throw new Error("HTTPS feed timeout must be between 1000 and 30000 milliseconds");
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function within(promise, milliseconds, message) {
|
|
80
|
+
let timer;
|
|
81
|
+
try {
|
|
82
|
+
return await Promise.race([
|
|
83
|
+
promise,
|
|
84
|
+
new Promise((_resolve, reject) => {
|
|
85
|
+
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
86
|
+
})
|
|
87
|
+
]);
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function httpsFeedUrl(baseUrl, feedId) {
|
|
94
|
+
if (!ID_RE.test(feedId || "")) throw new Error("feedId must be a stable, whitespace-free identifier");
|
|
95
|
+
const endpoint = validateHttpsObjectBase(baseUrl);
|
|
96
|
+
const name = createHash("sha256").update(feedId).digest("hex");
|
|
97
|
+
endpoint.pathname = `${endpoint.pathname}/feeds/${name}.json`;
|
|
98
|
+
return endpoint.toString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateEntry(entry) {
|
|
102
|
+
if (!exactKeys(entry, [
|
|
103
|
+
"sequence", "snapshotId", "snapshotDigest", "previousDigest", "publishedAt", "authority", "digest"
|
|
104
|
+
]) || !Number.isSafeInteger(entry.sequence) || entry.sequence < 1
|
|
105
|
+
|| !ID_RE.test(entry.snapshotId || "") || !DIGEST_RE.test(entry.snapshotDigest || "")
|
|
106
|
+
|| !(entry.previousDigest === null || DIGEST_RE.test(entry.previousDigest))
|
|
107
|
+
|| !validDate(entry.publishedAt) || entry.authority !== "context-only"
|
|
108
|
+
|| !DIGEST_RE.test(entry.digest || "")) {
|
|
109
|
+
throw new Error("HTTPS feed entry is invalid");
|
|
110
|
+
}
|
|
111
|
+
const { digest: _digest, ...body } = entry;
|
|
112
|
+
if (digest(body) !== entry.digest) throw new Error("HTTPS feed entry failed its integrity check");
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validateHttpsFeed(envelope) {
|
|
117
|
+
const verified = verifyEnvelope(envelope, "manifest");
|
|
118
|
+
const feed = verified.payload;
|
|
119
|
+
if (!exactKeys(feed, [
|
|
120
|
+
"schema", "feedId", "scopeId", "adapterId", "sequence", "entries", "authority", "digest"
|
|
121
|
+
]) || feed.schema !== SCHEMA || !ID_RE.test(feed.feedId || "")
|
|
122
|
+
|| !ID_RE.test(feed.scopeId || "") || !ID_RE.test(feed.adapterId || "")
|
|
123
|
+
|| !Number.isSafeInteger(feed.sequence) || feed.sequence < 1
|
|
124
|
+
|| !Array.isArray(feed.entries) || feed.entries.length < 1 || feed.entries.length > MAX_ENTRIES
|
|
125
|
+
|| feed.authority !== "context-only" || !DIGEST_RE.test(feed.digest || "")) {
|
|
126
|
+
throw new Error("HTTPS feed is invalid");
|
|
127
|
+
}
|
|
128
|
+
if (Buffer.byteLength(JSON.stringify(envelope)) > MAX_FEED_BYTES) throw new Error("HTTPS feed exceeds 256 KiB");
|
|
129
|
+
const { digest: _digest, ...body } = feed;
|
|
130
|
+
if (digest(body) !== feed.digest) throw new Error("HTTPS feed failed its integrity check");
|
|
131
|
+
let previous = null;
|
|
132
|
+
for (const entry of feed.entries) {
|
|
133
|
+
validateEntry(entry);
|
|
134
|
+
if (previous) {
|
|
135
|
+
if (entry.sequence !== previous.sequence + 1 || entry.previousDigest !== previous.digest) {
|
|
136
|
+
throw new Error("HTTPS feed hash chain is broken");
|
|
137
|
+
}
|
|
138
|
+
} else if (entry.sequence === 1 && entry.previousDigest !== null) {
|
|
139
|
+
throw new Error("HTTPS feed genesis entry cannot have a predecessor");
|
|
140
|
+
} else if (entry.sequence > 1 && entry.previousDigest === null) {
|
|
141
|
+
throw new Error("truncated HTTPS feed window must retain its predecessor digest");
|
|
142
|
+
}
|
|
143
|
+
previous = entry;
|
|
144
|
+
}
|
|
145
|
+
if (previous.sequence !== feed.sequence) throw new Error("HTTPS feed sequence does not match its latest entry");
|
|
146
|
+
return { envelope, feed, authentication: verified.authentication, publicIdentity: verified.publicIdentity };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function nextFeedBody({ current, feedId, snapshot, publishedAt }) {
|
|
150
|
+
if (current && (current.feedId !== feedId || current.scopeId !== snapshot.scopeId
|
|
151
|
+
|| current.adapterId !== snapshot.adapterId)) {
|
|
152
|
+
throw new Error("HTTPS feed identity does not match the snapshot");
|
|
153
|
+
}
|
|
154
|
+
const latest = current?.entries.at(-1) || null;
|
|
155
|
+
const entryBody = {
|
|
156
|
+
sequence: (latest?.sequence || 0) + 1,
|
|
157
|
+
snapshotId: snapshot.snapshotId,
|
|
158
|
+
snapshotDigest: snapshot.digest,
|
|
159
|
+
previousDigest: latest?.digest || null,
|
|
160
|
+
publishedAt,
|
|
161
|
+
authority: "context-only"
|
|
162
|
+
};
|
|
163
|
+
const entry = { ...entryBody, digest: digest(entryBody) };
|
|
164
|
+
const entries = [...(current?.entries || []), entry].slice(-MAX_ENTRIES);
|
|
165
|
+
const body = {
|
|
166
|
+
schema: SCHEMA, feedId, scopeId: snapshot.scopeId, adapterId: snapshot.adapterId,
|
|
167
|
+
sequence: entry.sequence, entries, authority: "context-only"
|
|
168
|
+
};
|
|
169
|
+
return { ...body, digest: digest(body) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resolvedRequest({
|
|
173
|
+
url, method, headers, body = null, timeoutMs, allowPrivateNetwork, lookup, request, accepted
|
|
174
|
+
}) {
|
|
175
|
+
const resolved = await within(
|
|
176
|
+
resolveHttpsEndpoint(url, { allowPrivateNetwork, lookup }), timeoutMs,
|
|
177
|
+
"HTTPS feed DNS resolution timed out"
|
|
178
|
+
);
|
|
179
|
+
const selected = resolved.addresses[0];
|
|
180
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
181
|
+
let deadline;
|
|
182
|
+
let settled = false;
|
|
183
|
+
const finish = (operation, value) => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
clearTimeout(deadline);
|
|
187
|
+
operation(value);
|
|
188
|
+
};
|
|
189
|
+
const options = {
|
|
190
|
+
protocol: "https:", hostname: resolved.lookupHostname,
|
|
191
|
+
port: resolved.endpoint.port || 443, path: resolved.endpoint.pathname,
|
|
192
|
+
method, headers, agent: false, autoSelectFamily: false,
|
|
193
|
+
servername: isIP(resolved.lookupHostname) ? undefined : resolved.lookupHostname,
|
|
194
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
195
|
+
const family = typeof lookupOptions === "object" && lookupOptions.family
|
|
196
|
+
? Number(lookupOptions.family) : 0;
|
|
197
|
+
const usable = family ? resolved.addresses.find((item) => item.family === family) : selected;
|
|
198
|
+
if (!usable) return callback(new Error("no vetted DNS address matches the requested family"));
|
|
199
|
+
return callback(null, usable.address, usable.family);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const active = request(options, (response) => {
|
|
203
|
+
const status = Number(response.statusCode || 0);
|
|
204
|
+
if (status >= 300 && status < 400 && status !== 304) {
|
|
205
|
+
response.resume();
|
|
206
|
+
finish(rejectPromise, new Error("HTTPS feed redirects are not followed"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const chunks = [];
|
|
210
|
+
let bytes = 0;
|
|
211
|
+
response.on("data", (chunk) => {
|
|
212
|
+
bytes += chunk.length;
|
|
213
|
+
if (bytes > MAX_FEED_BYTES) active.destroy(new Error("HTTPS feed exceeds 256 KiB"));
|
|
214
|
+
else chunks.push(chunk);
|
|
215
|
+
});
|
|
216
|
+
response.on("end", () => {
|
|
217
|
+
if (!accepted.includes(status)) {
|
|
218
|
+
finish(rejectPromise, new Error(`HTTPS feed request failed with status ${status}`));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
finish(resolvePromise, { status, headers: response.headers, body: Buffer.concat(chunks) });
|
|
222
|
+
});
|
|
223
|
+
response.on("error", (error) => finish(rejectPromise, error));
|
|
224
|
+
});
|
|
225
|
+
active.setTimeout(timeoutMs, () => active.destroy(new Error("HTTPS feed request timed out")));
|
|
226
|
+
deadline = setTimeout(() => active.destroy(new Error("HTTPS feed request timed out")), timeoutMs);
|
|
227
|
+
active.on("error", (error) => finish(rejectPromise, error));
|
|
228
|
+
active.end(body);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function fetchHttpsFeed({
|
|
233
|
+
baseUrl, feedId, tokenEnv = null, timeoutMs = 10000, allowPrivateNetwork = false,
|
|
234
|
+
confirmation = null, environment = process.env, lookup = dnsLookup, request = httpsRequest,
|
|
235
|
+
allowMissing = false
|
|
236
|
+
}) {
|
|
237
|
+
timeout(timeoutMs);
|
|
238
|
+
if (allowPrivateNetwork && confirmation !== CONFIRMATION) {
|
|
239
|
+
throw new Error("private-network HTTPS feed access requires explicit local owner confirmation");
|
|
240
|
+
}
|
|
241
|
+
const token = bearerToken(tokenEnv, environment);
|
|
242
|
+
const url = httpsFeedUrl(baseUrl, feedId);
|
|
243
|
+
const headers = {
|
|
244
|
+
accept: "application/vnd.agentspine.feed+json", "accept-encoding": "identity",
|
|
245
|
+
"user-agent": "AgentSpine/0.1 HTTPS feed transport"
|
|
246
|
+
};
|
|
247
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
248
|
+
const response = await resolvedRequest({
|
|
249
|
+
url, method: "GET", headers, timeoutMs, allowPrivateNetwork, lookup, request,
|
|
250
|
+
accepted: allowMissing ? [200, 404] : [200]
|
|
251
|
+
});
|
|
252
|
+
if (response.status === 404) return { missing: true, url, authenticatedRequest: Boolean(token) };
|
|
253
|
+
const contentType = String(response.headers["content-type"] || "").toLowerCase();
|
|
254
|
+
if (!/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/.test(contentType)) {
|
|
255
|
+
throw new Error("HTTPS feed response must use an application/json content type");
|
|
256
|
+
}
|
|
257
|
+
if (String(response.headers["content-encoding"] || "identity").toLowerCase() !== "identity") {
|
|
258
|
+
throw new Error("compressed HTTPS feeds are rejected");
|
|
259
|
+
}
|
|
260
|
+
const etag = String(response.headers.etag || "");
|
|
261
|
+
if (!ETAG_RE.test(etag)) throw new Error("HTTPS feed response requires one strong ETag");
|
|
262
|
+
let envelope;
|
|
263
|
+
try { envelope = JSON.parse(response.body.toString("utf8")); } catch { throw new Error("HTTPS feed response is not valid JSON"); }
|
|
264
|
+
const validated = validateHttpsFeed(envelope);
|
|
265
|
+
if (validated.feed.feedId !== feedId) throw new Error("HTTPS feed resource has the wrong feedId");
|
|
266
|
+
return { ...validated, missing: false, etag, url, authenticatedRequest: Boolean(token) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function putFeed({ baseUrl, feedId, envelope, etag, options }) {
|
|
270
|
+
const token = bearerToken(options.tokenEnv, options.environment);
|
|
271
|
+
const body = Buffer.from(JSON.stringify(envelope));
|
|
272
|
+
if (body.length > MAX_FEED_BYTES) throw new Error("HTTPS feed exceeds 256 KiB");
|
|
273
|
+
const headers = {
|
|
274
|
+
accept: "application/json", "accept-encoding": "identity",
|
|
275
|
+
"content-length": String(body.length), "content-type": "application/vnd.agentspine.feed+json",
|
|
276
|
+
"user-agent": "AgentSpine/0.1 HTTPS feed transport"
|
|
277
|
+
};
|
|
278
|
+
if (etag) headers["if-match"] = etag;
|
|
279
|
+
else headers["if-none-match"] = "*";
|
|
280
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
281
|
+
try {
|
|
282
|
+
return await resolvedRequest({
|
|
283
|
+
url: httpsFeedUrl(baseUrl, feedId), method: "PUT", headers, body,
|
|
284
|
+
timeoutMs: options.timeoutMs, allowPrivateNetwork: options.allowPrivateNetwork,
|
|
285
|
+
lookup: options.lookup, request: options.request, accepted: etag ? [200, 204] : [201, 204]
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (/status 412/.test(error.message)) throw new Error("HTTPS feed changed concurrently; fetch and retry");
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function publishHttpsFeed({
|
|
294
|
+
root = process.cwd(), directory, baseUrl, feedId, signerId,
|
|
295
|
+
snapshotId = `snapshot:${randomUUID()}`, now = new Date(), tokenEnv = null,
|
|
296
|
+
timeoutMs = 10000, allowPrivateNetwork = false, confirmation = null,
|
|
297
|
+
environment = process.env, lookup = dnsLookup, request = httpsRequest
|
|
298
|
+
}) {
|
|
299
|
+
if (confirmation !== CONFIRMATION) throw new Error("HTTPS feed publishing requires explicit local owner confirmation");
|
|
300
|
+
if (!ID_RE.test(signerId || "")) throw new Error("signerId is required for HTTPS feed publishing");
|
|
301
|
+
timeout(timeoutMs);
|
|
302
|
+
const publishedAt = isoDate(now, "now");
|
|
303
|
+
const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
|
|
304
|
+
const transportOptions = {
|
|
305
|
+
tokenEnv, timeoutMs, allowPrivateNetwork, confirmation, environment, lookup, request
|
|
306
|
+
};
|
|
307
|
+
const object = await putHttpsSnapshot({ baseUrl, snapshot, ...transportOptions });
|
|
308
|
+
const current = await fetchHttpsFeed({ baseUrl, feedId, allowMissing: true, ...transportOptions });
|
|
309
|
+
if (!current.missing && current.authentication.signerId !== signerId) {
|
|
310
|
+
throw new Error("HTTPS feed signer cannot change during publication");
|
|
311
|
+
}
|
|
312
|
+
const feed = nextFeedBody({ current: current.feed, feedId, snapshot, publishedAt });
|
|
313
|
+
const envelope = await signEnvelope({ root, signerId, kind: "manifest", payload: feed, now });
|
|
314
|
+
if (!current.missing && current.publicIdentity.keyId !== envelope.signer.keyId) {
|
|
315
|
+
throw new Error("HTTPS feed signing key cannot change; use a new feedId after key rotation");
|
|
316
|
+
}
|
|
317
|
+
await putFeed({ baseUrl, feedId, envelope, etag: current.etag || null, options: transportOptions });
|
|
318
|
+
const verified = await fetchHttpsFeed({ baseUrl, feedId, ...transportOptions });
|
|
319
|
+
if (verified.feed.digest !== feed.digest || verified.authentication.signerId !== signerId) {
|
|
320
|
+
throw new Error("HTTPS feed read-back does not match the published chain tip");
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
createdFeed: current.missing, sequence: feed.sequence, feedId, feedDigest: feed.digest,
|
|
324
|
+
entryDigest: feed.entries.at(-1).digest, snapshotId: snapshot.snapshotId,
|
|
325
|
+
snapshotDigest: snapshot.digest, objectUrl: object.objectUrl, feedUrl: verified.url,
|
|
326
|
+
objectCreated: object.created, verified: true, authenticatedWrite: Boolean(bearerToken(tokenEnv, environment)),
|
|
327
|
+
authority: "context-only"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function emptyState(root) {
|
|
332
|
+
return { schema: STATE_SCHEMA, root, feeds: [], history: [] };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validateReceipt(record) {
|
|
336
|
+
return exactKeys(record, [
|
|
337
|
+
"feedId", "scopeId", "adapterId", "sequence", "entryDigest", "snapshotDigest",
|
|
338
|
+
"signerId", "keyId", "observedAt", "authority"
|
|
339
|
+
]) && ID_RE.test(record.feedId || "") && ID_RE.test(record.scopeId || "")
|
|
340
|
+
&& ID_RE.test(record.adapterId || "") && Number.isSafeInteger(record.sequence) && record.sequence >= 1
|
|
341
|
+
&& DIGEST_RE.test(record.entryDigest || "") && DIGEST_RE.test(record.snapshotDigest || "")
|
|
342
|
+
&& ID_RE.test(record.signerId || "") && ID_RE.test(record.keyId || "")
|
|
343
|
+
&& validDate(record.observedAt) && record.authority === "context-only";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function validateState(state, root) {
|
|
347
|
+
if (!exactKeys(state, ["schema", "root", "feeds", "history"]) || state.schema !== STATE_SCHEMA
|
|
348
|
+
|| state.root !== root || !Array.isArray(state.feeds) || !Array.isArray(state.history)
|
|
349
|
+
|| state.feeds.some((item) => !validateReceipt(item)) || state.history.some((item) => !validateReceipt(item))) {
|
|
350
|
+
throw new Error("HTTPS feed state is invalid");
|
|
351
|
+
}
|
|
352
|
+
if (new Set(state.feeds.map((item) => item.feedId)).size !== state.feeds.length) {
|
|
353
|
+
throw new Error("HTTPS feed state contains duplicate feeds");
|
|
354
|
+
}
|
|
355
|
+
return state;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function feedStatePaths(root) {
|
|
359
|
+
const catalog = await buildCatalog(root);
|
|
360
|
+
const directory = await projectStateDir(catalog.root);
|
|
361
|
+
if (!statePathIsScanExcluded(catalog, directory)) {
|
|
362
|
+
throw new Error("HTTPS feed state must remain outside the scanned project or in an excluded home-state root");
|
|
363
|
+
}
|
|
364
|
+
return { root: catalog.root, path: join(directory, "feed-state.json") };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function readState(path, root) {
|
|
368
|
+
try {
|
|
369
|
+
const metadata = await stat(path);
|
|
370
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("HTTPS feed state exceeds 4 MiB");
|
|
371
|
+
return validateState(JSON.parse(await readFile(path, "utf8")), root);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code !== "ENOENT") throw error;
|
|
374
|
+
return emptyState(root);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function acquireLock(path) {
|
|
379
|
+
const lockPath = `${path}.lock`;
|
|
380
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
381
|
+
try { return { handle: await open(lockPath, "wx", 0o600), lockPath }; } catch (error) {
|
|
382
|
+
if (!isFileLockContention(error)) throw error;
|
|
383
|
+
try {
|
|
384
|
+
const metadata = await stat(lockPath);
|
|
385
|
+
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
386
|
+
} catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
|
|
387
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
throw new Error("HTTPS feed state is busy; retry shortly");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function saveState(path, state) {
|
|
394
|
+
validateState(state, state.root);
|
|
395
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
396
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("HTTPS feed state exceeds 4 MiB");
|
|
397
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
398
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
399
|
+
await replaceFileWithRetry(temporary, path);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function assessFeed(feed, receipt) {
|
|
403
|
+
const latest = feed.entries.at(-1);
|
|
404
|
+
if (!receipt) return { latest, unchanged: false };
|
|
405
|
+
if (receipt.scopeId !== feed.scopeId || receipt.adapterId !== feed.adapterId) {
|
|
406
|
+
throw new Error("HTTPS feed scope or adapter changed");
|
|
407
|
+
}
|
|
408
|
+
if (latest.sequence < receipt.sequence) throw new Error("HTTPS feed rollback detected");
|
|
409
|
+
if (latest.sequence === receipt.sequence) {
|
|
410
|
+
if (latest.digest !== receipt.entryDigest || latest.snapshotDigest !== receipt.snapshotDigest) {
|
|
411
|
+
throw new Error("HTTPS feed equivocation detected at an observed sequence");
|
|
412
|
+
}
|
|
413
|
+
return { latest, unchanged: true };
|
|
414
|
+
}
|
|
415
|
+
const anchor = feed.entries.findIndex((entry) => (
|
|
416
|
+
entry.sequence === receipt.sequence && entry.digest === receipt.entryDigest
|
|
417
|
+
));
|
|
418
|
+
if (anchor < 0) throw new Error("HTTPS feed continuity window no longer contains the last observed entry");
|
|
419
|
+
return { latest, unchanged: false };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function loadHttpsFeedState(root = process.cwd()) {
|
|
423
|
+
const paths = await feedStatePaths(root);
|
|
424
|
+
return { state: await readState(paths.path, paths.root), statePath: paths.path };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function inspectHttpsFeedState(root = process.cwd()) {
|
|
428
|
+
const paths = await feedStatePaths(root);
|
|
429
|
+
try {
|
|
430
|
+
return { state: await readState(paths.path, paths.root), statePath: paths.path, error: null };
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return { state: emptyState(paths.root), statePath: paths.path, error: error.message };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function pullHttpsFeed({
|
|
437
|
+
root = process.cwd(), baseUrl, feedId, now = new Date(), tokenEnv = null,
|
|
438
|
+
timeoutMs = 10000, allowPrivateNetwork = false, confirmation = null,
|
|
439
|
+
environment = process.env, lookup = dnsLookup, request = httpsRequest
|
|
440
|
+
}) {
|
|
441
|
+
const observedAt = isoDate(now, "now");
|
|
442
|
+
const options = {
|
|
443
|
+
baseUrl, feedId, tokenEnv, timeoutMs, allowPrivateNetwork,
|
|
444
|
+
confirmation, environment, lookup, request
|
|
445
|
+
};
|
|
446
|
+
const remote = await fetchHttpsFeed(options);
|
|
447
|
+
const { trust } = await loadTrust(root);
|
|
448
|
+
assertTrustedIdentity(remote.publicIdentity, trust);
|
|
449
|
+
const paths = await feedStatePaths(root);
|
|
450
|
+
const { handle, lockPath } = await acquireLock(paths.path);
|
|
451
|
+
try {
|
|
452
|
+
const state = await readState(paths.path, paths.root);
|
|
453
|
+
const existing = state.feeds.find((item) => item.feedId === feedId) || null;
|
|
454
|
+
if (existing && (existing.signerId !== remote.publicIdentity.signerId
|
|
455
|
+
|| existing.keyId !== remote.publicIdentity.keyId)) {
|
|
456
|
+
throw new Error("HTTPS feed signing identity changed; use a new feedId after key rotation");
|
|
457
|
+
}
|
|
458
|
+
const assessment = assessFeed(remote.feed, existing);
|
|
459
|
+
if (assessment.unchanged) {
|
|
460
|
+
return {
|
|
461
|
+
changed: false, feedId, sequence: existing.sequence,
|
|
462
|
+
snapshotDigest: existing.snapshotDigest, statePath: paths.path,
|
|
463
|
+
authenticatedRequest: remote.authenticatedRequest, authority: "context-only"
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const objectUrl = httpsObjectUrl(baseUrl, assessment.latest.snapshotDigest);
|
|
467
|
+
const fetched = await fetchHttpsSnapshot({
|
|
468
|
+
url: objectUrl, tokenEnv, timeoutMs, allowPrivateNetwork,
|
|
469
|
+
confirmation, environment, lookup, request
|
|
470
|
+
});
|
|
471
|
+
if (fetched.snapshot.digest !== assessment.latest.snapshotDigest
|
|
472
|
+
|| fetched.snapshot.snapshotId !== assessment.latest.snapshotId
|
|
473
|
+
|| fetched.snapshot.scopeId !== remote.feed.scopeId
|
|
474
|
+
|| fetched.snapshot.adapterId !== remote.feed.adapterId) {
|
|
475
|
+
throw new Error("HTTPS feed snapshot does not match its signed entry");
|
|
476
|
+
}
|
|
477
|
+
const imported = await importHttpsSnapshot({ root: paths.root, snapshot: fetched.snapshot, now });
|
|
478
|
+
const receipt = {
|
|
479
|
+
feedId, scopeId: remote.feed.scopeId, adapterId: remote.feed.adapterId,
|
|
480
|
+
sequence: assessment.latest.sequence, entryDigest: assessment.latest.digest,
|
|
481
|
+
snapshotDigest: assessment.latest.snapshotDigest,
|
|
482
|
+
signerId: remote.publicIdentity.signerId, keyId: remote.publicIdentity.keyId,
|
|
483
|
+
observedAt, authority: "context-only"
|
|
484
|
+
};
|
|
485
|
+
if (existing) state.history.push(existing);
|
|
486
|
+
state.feeds = [...state.feeds.filter((item) => item.feedId !== feedId), receipt]
|
|
487
|
+
.sort((a, b) => a.feedId.localeCompare(b.feedId));
|
|
488
|
+
await saveState(paths.path, state);
|
|
489
|
+
return {
|
|
490
|
+
...imported, changed: true, feedId, sequence: receipt.sequence,
|
|
491
|
+
entryDigest: receipt.entryDigest, snapshotDigest: receipt.snapshotDigest,
|
|
492
|
+
feedUrl: remote.url, objectUrl, statePath: paths.path,
|
|
493
|
+
authenticatedRequest: remote.authenticatedRequest, authority: "context-only"
|
|
494
|
+
};
|
|
495
|
+
} finally {
|
|
496
|
+
await handle.close();
|
|
497
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
498
|
+
}
|
|
499
|
+
}
|