@twin3-ai/agent-id 0.2.0 → 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.
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const crypto = require("node:crypto");
6
+
7
+ const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
8
+ const DEFAULT_USER_AGENTS = Object.freeze(["GPTBot", "ChatGPT-User", "OAI-SearchBot", "ClaudeBot", "Claude-User", "Google-Extended", "Googlebot", "PerplexityBot", "Amazonbot", "Bytespider"]);
9
+
10
+ function emptyState() {
11
+ return { schema: "agentx-cloudflare-a1-sync-state-v1", cursor_end: "", pending: null, retry_at: 0, attempts: 0, next_run_at: 0, last_sync_at: 0, last_error: null };
12
+ }
13
+ function loadState(statePath) {
14
+ try {
15
+ const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
16
+ return { ...emptyState(), ...parsed };
17
+ } catch (error) {
18
+ if (error.code === "ENOENT") return emptyState();
19
+ throw new Error("cloudflare_a1_state_invalid");
20
+ }
21
+ }
22
+ function saveState(statePath, state) {
23
+ const target = path.resolve(statePath);
24
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
25
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
26
+ try {
27
+ fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" });
28
+ fs.renameSync(temporary, target);
29
+ fs.chmodSync(target, 0o600);
30
+ } finally { try { fs.rmSync(temporary, { force: true }); } catch (_error) {} }
31
+ }
32
+ function cleanPath(value) {
33
+ const raw = String(value || "/");
34
+ try { return (new URL(raw, "https://redacted.invalid").pathname || "/").slice(0, 240); }
35
+ catch (_error) { return (raw.split(/[?#]/, 1)[0] || "/").slice(0, 240); }
36
+ }
37
+ function userAgentProduct(value) {
38
+ const match = String(value || "").trim().match(/^([A-Za-z][A-Za-z0-9_-]{0,63})(?:\/[^\s]+)?/);
39
+ return match ? match[1] : "";
40
+ }
41
+ function buildQuery(zoneId, start, end, userAgents) {
42
+ const filters = userAgents.map((item) => `{userAgent_like:${JSON.stringify(`%${item}%`)}}`).join(" ");
43
+ return `query AgentIdA1 { viewer { zones(filter:{zoneTag:${JSON.stringify(zoneId)}}) { a1:httpRequestsAdaptiveGroups(filter:{datetime_geq:${JSON.stringify(start)} datetime_leq:${JSON.stringify(end)} requestSource:"eyeball" OR:[${filters}]} limit:5000 orderBy:[count_DESC]) { count dimensions { userAgent clientRequestPath edgeResponseStatus } sum { edgeResponseBytes } } } } }`;
44
+ }
45
+ async function defaultFetchCloudflare({ token, query, fetchImpl = globalThis.fetch }) {
46
+ if (typeof fetchImpl !== "function") throw new Error("cloudflare_a1_fetch_unavailable");
47
+ const response = await fetchImpl(ENDPOINT, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ query }), redirect: "error" });
48
+ if (!response.ok) throw new Error(`cloudflare_a1_http_${response.status}`);
49
+ const body = await response.json();
50
+ if (Array.isArray(body.errors) && body.errors.length) throw new Error("cloudflare_a1_provider_error");
51
+ return body;
52
+ }
53
+ function responseRows(response, siteUrl, observedAt, windowKey = "") {
54
+ const groups = response && response.data && response.data.viewer && Array.isArray(response.data.viewer.zones) ? response.data.viewer.zones[0] && response.data.viewer.zones[0].a1 : null;
55
+ if (!Array.isArray(groups)) throw new Error("cloudflare_a1_response_invalid");
56
+ const host = new URL(siteUrl).hostname.toLowerCase();
57
+ return groups.slice(0, 5000).flatMap((group) => {
58
+ const dimensions = group && group.dimensions || {};
59
+ const userAgent = userAgentProduct(dimensions.userAgent);
60
+ const count = Math.max(0, Math.floor(Number(group && group.count || 0)));
61
+ const status = Math.floor(Number(dimensions.edgeResponseStatus || 0));
62
+ if (!userAgent || count < 1 || status < 100 || status > 599) return [];
63
+ const clean = cleanPath(dimensions.clientRequestPath);
64
+ const eventId = `cfa1_${crypto.createHash("sha256").update(JSON.stringify([host, windowKey, userAgent, clean, status])).digest("hex").slice(0, 32)}`;
65
+ return [{ event_id: eventId, host, path: clean, user_agent: userAgent, status, bytes: Math.max(0, Math.floor(Number(group && group.sum && group.sum.edgeResponseBytes || 0))), request_count: count, timestamp: observedAt, source: "cloudflare_ai_crawl_control" }];
66
+ });
67
+ }
68
+ function createCloudflareA1Sync({ client, statePath, siteUrl, zoneId, apiToken, userAgents = DEFAULT_USER_AGENTS, lookbackMs = 3600000, intervalMs = 900000, maxPendingRows = 5000, fetchCloudflare = defaultFetchCloudflare, now = Date.now, random = Math.random } = {}) {
69
+ if (!client || typeof client.aiReads !== "function" || !statePath || !siteUrl || !zoneId || !apiToken) throw new Error("cloudflare_a1_sync_configuration_invalid");
70
+ const normalizedUrl = new URL(siteUrl).origin;
71
+ const agents = [...new Set((userAgents || []).map(userAgentProduct).filter(Boolean))].slice(0, 50);
72
+ if (!agents.length) throw new Error("cloudflare_a1_user_agents_required");
73
+ const state = loadState(statePath);
74
+ const persist = () => saveState(statePath, state);
75
+ async function upload(rows, windowEnd) {
76
+ try {
77
+ const result = await client.aiReads({ url: normalizedUrl, logs: rows, source: "cloudflare_a1_sync" });
78
+ state.pending = null; state.cursor_end = windowEnd; state.retry_at = 0; state.attempts = 0; state.last_sync_at = now(); state.next_run_at = state.last_sync_at + Math.max(60000, intervalMs); state.last_error = null; persist();
79
+ return { ok: true, status: "synced", rows: rows.length, requests: rows.reduce((sum, row) => sum + row.request_count, 0), stored: Number(result && result.stored || 0), cursor_end: state.cursor_end };
80
+ } catch (error) {
81
+ state.pending = { rows: rows.slice(0, maxPendingRows), window_end: windowEnd }; state.attempts += 1;
82
+ const backoff = Math.min(300000, 1000 * (2 ** Math.min(8, state.attempts - 1))); state.retry_at = now() + Math.round(backoff * (0.5 + random())); state.last_error = { code: String(error.code || "a1_delivery_failed").slice(0, 80) }; persist();
83
+ return { ok: false, status: "queued_for_retry", pending_rows: state.pending.rows.length, retry_at: state.retry_at };
84
+ }
85
+ }
86
+ async function runOnce({ force = false } = {}) {
87
+ const timestamp = now();
88
+ if (!force && state.retry_at > timestamp) return { ok: false, status: "retry_wait", retry_at: state.retry_at, pending_rows: state.pending ? state.pending.rows.length : 0 };
89
+ if (state.pending) return upload(state.pending.rows, state.pending.window_end);
90
+ if (!force && state.next_run_at > timestamp) return { ok: true, status: "not_due", next_run_at: state.next_run_at };
91
+ const bucketMs = Math.max(60000, intervalMs);
92
+ const endMs = Math.floor(timestamp / bucketMs) * bucketMs;
93
+ const end = new Date(endMs).toISOString();
94
+ const start = state.cursor_end || new Date(timestamp - Math.max(60000, lookbackMs)).toISOString();
95
+ if (Date.parse(start) >= endMs) return { ok: true, status: "not_due", next_run_at: endMs + bucketMs };
96
+ const response = await fetchCloudflare({ token: apiToken, query: buildQuery(zoneId, start, end, agents) });
97
+ return upload(responseRows(response, normalizedUrl, timestamp, `${start}/${end}`), end);
98
+ }
99
+ async function status() {
100
+ return { schema: state.schema, provider: "cloudflare_ai_crawl_control", host: new URL(normalizedUrl).hostname, cursor_end: state.cursor_end, pending_rows: state.pending ? state.pending.rows.length : 0, retry_at: state.retry_at, next_run_at: state.next_run_at, last_sync_at: state.last_sync_at, last_error: state.last_error };
101
+ }
102
+ return Object.freeze({ runOnce, status });
103
+ }
104
+
105
+ module.exports = { DEFAULT_USER_AGENTS, buildQuery, responseRows, createCloudflareA1Sync };
package/domain-proof.js CHANGED
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
 
3
3
  const fs = require("node:fs/promises");
4
+ const fsSync = require("node:fs");
4
5
  const path = require("node:path");
5
6
  const crypto = require("node:crypto");
6
7
 
7
8
  const PROOF_SCHEMA = "agentx-domain-proof-v1";
8
9
  const CHALLENGE_SCHEMA = "agentx-domain-challenge-v1";
9
10
  const SITE_VERIFICATION_SCHEMA = "agentx-site-agent-verification-v1";
11
+ const IDENTITY_SCHEMA = "agentx-site-identity-v1";
10
12
  const PROOF_RELATIVE_PATH = path.join(".well-known", "agent-id.json");
11
13
 
12
14
  function proofError(code, message) {
@@ -34,6 +36,151 @@ function exactHostname(value) {
34
36
  return host;
35
37
  }
36
38
 
39
+ function stableValue(value) {
40
+ if (Array.isArray(value)) return value.map(stableValue);
41
+ if (!value || typeof value !== "object") return value;
42
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
43
+ }
44
+
45
+ function canonicalBytes(value) {
46
+ return Buffer.from(JSON.stringify(stableValue(value)), "utf8");
47
+ }
48
+
49
+ function sha256(value) {
50
+ return `sha256:${crypto.createHash("sha256").update(canonicalBytes(value)).digest("hex")}`;
51
+ }
52
+
53
+ function serviceOrigin(value) {
54
+ let parsed;
55
+ try { parsed = new URL(String(value || "")); } catch (_error) {
56
+ throw proofError("invalid_service_origin", "serviceOrigin must be an HTTPS origin");
57
+ }
58
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
59
+ throw proofError("invalid_service_origin", "serviceOrigin must be an HTTPS origin");
60
+ }
61
+ return parsed.origin;
62
+ }
63
+
64
+ function buildEnterpriseIdentityDocument({ state, credential, identity, serviceOrigin: issuerOrigin }) {
65
+ if (!state || state.status !== "verified_domain") {
66
+ throw proofError("identity_not_verified", "domain verification must complete before publishing identity");
67
+ }
68
+ if (!credential || credential.status !== "active") {
69
+ throw proofError("credential_not_active", "an active enterprise credential is required");
70
+ }
71
+ if (!identity || !identity.key_path || !identity.public_key_pem || !identity.public_key_thumbprint) {
72
+ throw proofError("identity_key_missing", "the customer Ed25519 identity is required");
73
+ }
74
+ const host = exactHostname(credential.host);
75
+ const agentId = requiredString(credential.agent_id, "credential.agent_id");
76
+ const environment = requiredString(credential.environment, "credential.environment");
77
+ const thumbprint = requiredString(credential.public_key_thumbprint, "credential.public_key_thumbprint");
78
+ const site = new URL(requiredString(state.site_url, "state.site_url"));
79
+ const issuer = serviceOrigin(issuerOrigin);
80
+ if (
81
+ site.protocol !== "https:" || site.hostname.toLowerCase() !== host ||
82
+ state.agent_id !== agentId || state.environment !== environment ||
83
+ identity.public_key_thumbprint !== thumbprint ||
84
+ (state.public_key_thumbprint && state.public_key_thumbprint !== thumbprint)
85
+ ) {
86
+ throw proofError("identity_subject_mismatch", "state, credential, site, and signing key must identify the same Agent");
87
+ }
88
+ const publicKey = crypto.createPublicKey(identity.public_key_pem);
89
+ const publicKeyJwk = publicKey.export({ format: "jwk" });
90
+ const body = {
91
+ schema: IDENTITY_SCHEMA,
92
+ agent_id: agentId,
93
+ tenant_id: requiredString(credential.tenant_id, "credential.tenant_id"),
94
+ host,
95
+ environment,
96
+ controller: `https://${host}`,
97
+ issuer: { id: issuer, name: "twin3 AgentX.ID" },
98
+ verification_method: {
99
+ id: `https://${host}/.well-known/agent-id.json#site-key`,
100
+ type: "JsonWebKey2020",
101
+ controller: `https://${host}`,
102
+ publicKeyJwk,
103
+ public_key_thumbprint: thumbprint,
104
+ },
105
+ credential_status: {
106
+ credential_id: requiredString(credential.credential_id, "credential.credential_id"),
107
+ state: "active",
108
+ issued_at: Number(credential.issued_at || state.verified_at || 0),
109
+ expires_at: Number(credential.expires_at || 0),
110
+ status_url: `${issuer}/agentx/id/${agentId}/status.json`,
111
+ },
112
+ services: {
113
+ passport: `${issuer}/agentx/id/${agentId}.json`,
114
+ proof_graph: `${issuer}/agentx/id/${agentId}/proof.json`,
115
+ agent_card: `https://${host}/.well-known/agent-card.json`,
116
+ },
117
+ trust_anchors: {
118
+ domain: { status: "verified", method: "well_known" },
119
+ erc_8004: { status: "not_registered" },
120
+ google_marketplace: { status: "not_asserted" },
121
+ },
122
+ non_claims: [
123
+ "not_a_google_issued_identity",
124
+ "not_registered_in_erc_8004",
125
+ "does_not_grant_website_mutation_authority",
126
+ ],
127
+ };
128
+ body.content_hash = sha256(body);
129
+ const privateKey = crypto.createPrivateKey(fsSync.readFileSync(identity.key_path, "utf8"));
130
+ const signature = crypto.sign(null, canonicalBytes(body), privateKey).toString("base64url");
131
+ return {
132
+ ...body,
133
+ proof: {
134
+ type: "DataIntegrityProof",
135
+ cryptosuite: "eddsa-agentx-stable-json-v1",
136
+ proofPurpose: "assertionMethod",
137
+ verificationMethod: body.verification_method.id,
138
+ proofValue: `base64url:${signature}`,
139
+ },
140
+ };
141
+ }
142
+
143
+ function verifyEnterpriseIdentityDocument(document, { now = Math.floor(Date.now() / 1000) } = {}) {
144
+ const failures = [];
145
+ if (!document || document.schema !== IDENTITY_SCHEMA) failures.push("unsupported_identity_schema");
146
+ const proof = document && document.proof || {};
147
+ const signed = document && typeof document === "object" ? { ...document } : {};
148
+ delete signed.proof;
149
+ const hashInput = { ...signed };
150
+ delete hashInput.content_hash;
151
+ if (!signed.content_hash || signed.content_hash !== sha256(hashInput)) failures.push("content_hash_mismatch");
152
+ const method = signed.verification_method || {};
153
+ if (signed.controller !== `https://${signed.host}` || method.controller !== signed.controller) failures.push("controller_binding_mismatch");
154
+ if (!/^agt_[A-Za-z0-9_-]{4,128}$/.test(String(signed.agent_id || ""))) failures.push("agent_id_invalid");
155
+ if (proof.verificationMethod !== method.id || proof.cryptosuite !== "eddsa-agentx-stable-json-v1") failures.push("proof_metadata_mismatch");
156
+ try {
157
+ const publicKey = crypto.createPublicKey({ key: method.publicKeyJwk, format: "jwk" });
158
+ const publicDer = publicKey.export({ type: "spki", format: "der" });
159
+ const actualThumbprint = `sha256:${crypto.createHash("sha256").update(publicDer).digest("base64url")}`;
160
+ if (actualThumbprint !== method.public_key_thumbprint) failures.push("public_key_thumbprint_mismatch");
161
+ const encoded = String(proof.proofValue || "");
162
+ const signature = encoded.startsWith("base64url:") ? Buffer.from(encoded.slice(10), "base64url") : null;
163
+ if (!signature || !crypto.verify(null, canonicalBytes(signed), publicKey, signature)) failures.push("proof_value_mismatch");
164
+ } catch (_error) {
165
+ failures.push("public_key_invalid");
166
+ }
167
+ const expiry = Number((signed.credential_status || {}).expires_at || 0);
168
+ if (!Number.isFinite(expiry) || expiry <= Number(now)) failures.push("credential_expired");
169
+ if ((signed.credential_status || {}).state !== "active") failures.push("credential_not_active");
170
+ return {
171
+ schema: "agentx-site-identity-verification-v1",
172
+ ok: failures.length === 0,
173
+ agent_id: signed.agent_id || "",
174
+ host: signed.host || "",
175
+ failure_codes: [...new Set(failures)],
176
+ offline_signature_valid: !failures.some(code => ["content_hash_mismatch", "controller_binding_mismatch", "proof_metadata_mismatch", "proof_value_mismatch", "public_key_invalid", "public_key_thumbprint_mismatch"].includes(code)),
177
+ offline_trust_level: "self_signature_only",
178
+ trust_established: false,
179
+ online_status_required: true,
180
+ status_url: (signed.credential_status || {}).status_url || "",
181
+ };
182
+ }
183
+
37
184
  function buildWellKnownProofDocument(challenge, { now = null } = {}) {
38
185
  const isChallenge = challenge && challenge.schema === CHALLENGE_SCHEMA && challenge.method === "well_known";
39
186
  const isProof = challenge && challenge.schema === PROOF_SCHEMA;
@@ -196,13 +343,59 @@ async function writeSiteAgentVerificationDocument({ registration, projectRoot, p
196
343
  };
197
344
  }
198
345
 
346
+ async function writeEnterpriseIdentityDocument({ document, projectRoot, publicRoot, approved = false }) {
347
+ if (approved !== true) {
348
+ throw proofError("approval_required", "publishing a durable Agent identity requires explicit approval");
349
+ }
350
+ const verification = verifyEnterpriseIdentityDocument(document);
351
+ if (!verification.ok) {
352
+ throw proofError("identity_document_invalid", verification.failure_codes.join(","));
353
+ }
354
+ const { project, root, directory, target } = resolveProofTarget({ projectRoot, publicRoot });
355
+ const [realProject, realRoot] = await Promise.all([fs.realpath(project), fs.realpath(root)]);
356
+ if (realRoot !== realProject && !realRoot.startsWith(realProject + path.sep)) {
357
+ throw proofError("unsafe_public_root", "publicRoot cannot escape the customer project through a symlink");
358
+ }
359
+ await fs.mkdir(directory, { recursive: true, mode: 0o755 });
360
+ const existing = await readExisting(target);
361
+ if (existing !== null) {
362
+ let parsed;
363
+ try { parsed = JSON.parse(existing); } catch (_error) { parsed = null; }
364
+ const managed = parsed && [PROOF_SCHEMA, IDENTITY_SCHEMA].includes(parsed.schema);
365
+ if (!managed || parsed.host !== document.host || parsed.agent_id !== document.agent_id) {
366
+ throw proofError("unmanaged_proof_conflict", "refusing to overwrite a customer-managed file");
367
+ }
368
+ }
369
+ const serialized = `${JSON.stringify(document, null, 2)}\n`;
370
+ const temporary = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
371
+ try {
372
+ await fs.writeFile(temporary, serialized, { mode: 0o644, flag: "wx" });
373
+ await fs.rename(temporary, target);
374
+ } finally {
375
+ await fs.rm(temporary, { force: true }).catch(() => {});
376
+ }
377
+ return {
378
+ schema: "agentx-site-identity-write-receipt-v1",
379
+ changed: existing !== serialized,
380
+ path: target,
381
+ relative_path: "/.well-known/agent-id.json",
382
+ sha256: `sha256:${crypto.createHash("sha256").update(serialized).digest("hex")}`,
383
+ agent_id: document.agent_id,
384
+ host: document.host,
385
+ };
386
+ }
387
+
199
388
  module.exports = {
200
389
  PROOF_SCHEMA,
201
390
  SITE_VERIFICATION_SCHEMA,
391
+ IDENTITY_SCHEMA,
202
392
  PROOF_RELATIVE_PATH,
203
393
  buildWellKnownProofDocument,
204
394
  buildSiteAgentVerificationDocument,
395
+ buildEnterpriseIdentityDocument,
396
+ verifyEnterpriseIdentityDocument,
205
397
  resolveProofTarget,
206
398
  writeWellKnownProofDocument,
207
399
  writeSiteAgentVerificationDocument,
400
+ writeEnterpriseIdentityDocument,
208
401
  };
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Edge HTML injection lane.
5
+ *
6
+ * The repository connector is deliberately limited to five allowlisted
7
+ * replaceable machine-readable files, which means `seo_jsonld`, `seo_og`, and
8
+ * `geo_org_entity` could only ever be delivered as snippets for a human to paste into
9
+ * HTML templates. This module closes those checks by rewriting the HTML response
10
+ * inside the customer's own edge worker. `geo_sameas` closes only when the customer
11
+ * supplies explicit external profile URLs. This structural signal does not verify
12
+ * ownership or authority of those third-party profiles.
13
+ *
14
+ * The trust boundary is unchanged: this code runs in the customer's
15
+ * infrastructure, it never writes to the customer's source tree, and the site
16
+ * owner's existing markup always wins over injected markup.
17
+ */
18
+
19
+ const MAX_DOCUMENT_BYTES = 1024 * 1024;
20
+
21
+ const SECRET_PATTERNS = [
22
+ /\bak_aeo_[A-Za-z0-9_-]{6,}\b/,
23
+ /\bav_[A-Za-z0-9_-]{8,}\b/,
24
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/,
25
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/
26
+ ];
27
+
28
+ const INJECTION_MARKER = "data-agent-id-injected";
29
+
30
+ function injectionError(code, message) {
31
+ const error = new Error(message || code);
32
+ error.code = code;
33
+ return error;
34
+ }
35
+
36
+ function assertNoSecret(value) {
37
+ const text = typeof value === "string" ? value : JSON.stringify(value || "");
38
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) {
39
+ throw injectionError("EDGE_HTML_SECRET_DETECTED", "Injection content contains a credential-like value and was rejected.");
40
+ }
41
+ }
42
+
43
+ function escapeAttribute(value) {
44
+ return String(value == null ? "" : value)
45
+ .replace(/&/g, "&amp;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/</g, "&lt;")
48
+ .replace(/>/g, "&gt;");
49
+ }
50
+
51
+ /**
52
+ * Escape a JSON-LD payload so it can never break out of its script element.
53
+ * `<` is escaped as `\u003c`, which is valid JSON and inert inside HTML.
54
+ */
55
+ function serializeJsonLd(value) {
56
+ return JSON.stringify(value, null, 2).replace(/</g, "\\u003c");
57
+ }
58
+
59
+ function httpsUrl(value) {
60
+ const text = String(value || "").trim();
61
+ if (!text) return "";
62
+ if (!/^https:\/\//i.test(text)) throw injectionError("EDGE_HTML_INSECURE_URL", `Injection URLs must use https: ${text}`);
63
+ return text;
64
+ }
65
+
66
+ function normalizeSameAs(list) {
67
+ const values = Array.isArray(list) ? list : list ? [list] : [];
68
+ const seen = new Set();
69
+ const output = [];
70
+ for (const item of values) {
71
+ const url = httpsUrl(item);
72
+ if (!url || seen.has(url)) continue;
73
+ seen.add(url);
74
+ output.push(url);
75
+ }
76
+ return output;
77
+ }
78
+
79
+ function canonicalHost(value) {
80
+ const text = String(value || "").trim();
81
+ try {
82
+ return new URL(`https://${text}`).hostname.toLowerCase().replace(/\.$/, "");
83
+ } catch (_error) {
84
+ throw injectionError("EDGE_HTML_IDENTITY_REQUIRED", "Identity host must be a valid hostname.");
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build a reviewable injection plan. Nothing is applied here; the plan is the
90
+ * artifact an owner (or the optimization loop) approves.
91
+ */
92
+ function createHtmlInjectionPlan({ identity, organization = {}, openGraph = {}, now } = {}) {
93
+ if (!identity || !identity.host || !identity.base_url) {
94
+ throw injectionError("EDGE_HTML_IDENTITY_REQUIRED", "An identity with host and base_url is required.");
95
+ }
96
+ assertNoSecret(identity);
97
+ assertNoSecret(organization);
98
+ assertNoSecret(openGraph);
99
+
100
+ const baseUrl = httpsUrl(identity.base_url);
101
+ const identityHost = canonicalHost(identity.host);
102
+ const baseHost = new URL(baseUrl).hostname.toLowerCase().replace(/\.$/, "");
103
+ if (identityHost !== baseHost) {
104
+ throw injectionError("EDGE_HTML_IDENTITY_HOST_MISMATCH", "Identity host must match the HTTPS base URL hostname.");
105
+ }
106
+ const sameAs = normalizeSameAs(organization.sameAs);
107
+
108
+ const jsonld = {
109
+ "@context": "https://schema.org",
110
+ "@type": String(organization.type || "Organization"),
111
+ name: String(organization.name || identity.host),
112
+ url: baseUrl,
113
+ identifier: {
114
+ "@type": "PropertyValue",
115
+ propertyID: "AgentX.ID",
116
+ value: String(identity.agent_id || "")
117
+ }
118
+ };
119
+ if (sameAs.length) jsonld.sameAs = sameAs;
120
+ if (organization.logo) jsonld.logo = httpsUrl(organization.logo);
121
+ if (organization.description) jsonld.description = String(organization.description);
122
+
123
+ const og = [];
124
+ og.push({ property: "og:title", content: String(openGraph.title || organization.name || identity.host) });
125
+ if (openGraph.description) og.push({ property: "og:description", content: String(openGraph.description) });
126
+ og.push({ property: "og:url", content: baseUrl });
127
+ if (openGraph.image) og.push({ property: "og:image", content: httpsUrl(openGraph.image) });
128
+ og.push({ property: "og:type", content: String(openGraph.type || "website") });
129
+
130
+ const closes = ["seo_jsonld", "seo_og", "geo_org_entity"];
131
+ if (sameAs.length) closes.push("geo_sameas");
132
+
133
+ return Object.freeze({
134
+ schema: "agentx-edge-html-injection-plan-v1",
135
+ lane: "edge_html_injection",
136
+ approval_required: true,
137
+ customer_source_write_access: false,
138
+ host: identity.host,
139
+ base_url: baseUrl,
140
+ agent_id: String(identity.agent_id || ""),
141
+ verification_status: String(identity.verification_status || "unverified_scan"),
142
+ created_at: Number(now || Date.now()),
143
+ jsonld,
144
+ open_graph: og,
145
+ sameas_present: sameAs.length > 0,
146
+ closes_checks: closes,
147
+ owner_markup_wins: true,
148
+ non_claims: [
149
+ "does_not_write_to_customer_source_tree",
150
+ "runs_in_customer_edge_infrastructure",
151
+ "never_overrides_existing_owner_markup",
152
+ "does_not_guarantee_ai_citation_or_ranking"
153
+ ]
154
+ });
155
+ }
156
+
157
+ function hasJsonLdOrganization(head) {
158
+ const blocks = head.match(/<script[^>]+type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) || [];
159
+ return blocks.some((block) => /"@type"\s*:\s*"(Organization|LocalBusiness|Corporation)"/i.test(block));
160
+ }
161
+
162
+ function hasOgProperty(head, property) {
163
+ const escaped = String(property).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
164
+ return new RegExp(`<meta[^>]+property\\s*=\\s*["']${escaped}["']`, "i").test(head);
165
+ }
166
+
167
+ function alreadyInjected(head) {
168
+ return head.includes(INJECTION_MARKER);
169
+ }
170
+
171
+ /**
172
+ * Rewrite a single HTML document. Returns the original string untouched whenever
173
+ * the document is not safely rewritable, so the lane can never break a page.
174
+ */
175
+ function applyHtmlInjection(html, plan) {
176
+ if (!plan || plan.schema !== "agentx-edge-html-injection-plan-v1") {
177
+ throw injectionError("EDGE_HTML_PLAN_INVALID", "A valid edge HTML injection plan is required.");
178
+ }
179
+ const source = typeof html === "string" ? html : "";
180
+ if (Buffer.byteLength(source, "utf8") > MAX_DOCUMENT_BYTES) {
181
+ return { html: source, injected_checks: [], skipped: "document_too_large" };
182
+ }
183
+ const headClose = source.search(/<\/head\s*>/i);
184
+ if (headClose === -1) {
185
+ return { html: source, injected_checks: [], skipped: "no_head_element" };
186
+ }
187
+ const head = source.slice(0, headClose);
188
+ if (alreadyInjected(head)) {
189
+ return { html: source, injected_checks: [], skipped: "already_injected" };
190
+ }
191
+
192
+ const parts = [];
193
+ const injected = [];
194
+
195
+ if (!hasJsonLdOrganization(head)) {
196
+ parts.push(`<script type="application/ld+json" ${INJECTION_MARKER}="seo_jsonld">${serializeJsonLd(plan.jsonld)}</script>`);
197
+ injected.push("seo_jsonld", "geo_org_entity");
198
+ if (plan.sameas_present) injected.push("geo_sameas");
199
+ }
200
+
201
+ if (!hasOgProperty(head, "og:title")) {
202
+ const missingTags = plan.open_graph.filter((tag) => !hasOgProperty(head, tag.property));
203
+ for (const tag of missingTags) {
204
+ parts.push(`<meta property="${escapeAttribute(tag.property)}" content="${escapeAttribute(tag.content)}" ${INJECTION_MARKER}="seo_og">`);
205
+ }
206
+ if (missingTags.some((tag) => tag.property === "og:title")) injected.push("seo_og");
207
+ }
208
+
209
+ if (!parts.length) {
210
+ return { html: source, injected_checks: [], skipped: "owner_markup_already_present" };
211
+ }
212
+
213
+ const block = `\n${parts.join("\n")}\n`;
214
+ const output = source.slice(0, headClose) + block + source.slice(headClose);
215
+ return { html: output, injected_checks: [...new Set(injected)], skipped: null };
216
+ }
217
+
218
+ const REWRITABLE_CONTENT_TYPE = /^text\/html\b/i;
219
+
220
+ /**
221
+ * Wrap an origin fetch so HTML responses are rewritten and everything else is
222
+ * passed through byte-for-byte.
223
+ */
224
+ function createHtmlInjectionHandler({ plan, origin } = {}) {
225
+ if (typeof origin !== "function") throw injectionError("EDGE_HTML_ORIGIN_REQUIRED", "An origin fetch function is required.");
226
+ if (!plan || plan.schema !== "agentx-edge-html-injection-plan-v1") {
227
+ throw injectionError("EDGE_HTML_PLAN_INVALID", "A valid edge HTML injection plan is required.");
228
+ }
229
+ return async function handle(request) {
230
+ const response = await origin(request);
231
+ const method = String((request && request.method) || "GET").toUpperCase();
232
+ if (!["GET", "HEAD"].includes(method)) return response;
233
+ if (!response || response.status !== 200) return response;
234
+ const contentType = response.headers.get("Content-Type") || "";
235
+ if (!REWRITABLE_CONTENT_TYPE.test(contentType)) return response;
236
+
237
+ const body = await response.text();
238
+ const result = applyHtmlInjection(body, plan);
239
+ const headers = new Headers(response.headers);
240
+ if (result.injected_checks.length) {
241
+ headers.set("X-Agent-ID-Injected", result.injected_checks.join(","));
242
+ }
243
+ headers.delete("Content-Length");
244
+ return new Response(method === "HEAD" ? null : result.html, {
245
+ status: response.status,
246
+ statusText: response.statusText,
247
+ headers
248
+ });
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ MAX_DOCUMENT_BYTES,
254
+ INJECTION_MARKER,
255
+ createHtmlInjectionPlan,
256
+ applyHtmlInjection,
257
+ createHtmlInjectionHandler
258
+ };
@@ -152,6 +152,13 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
152
152
  return responseJson(await fetcher(`${origin}/api/site_agents/v1/tasks${pathname}`, request));
153
153
  }
154
154
 
155
+ async function dashboardRequest(pathname, { method = "GET", body = null } = {}) {
156
+ const accessToken = await session.getAccessToken(["insights:read"]);
157
+ const request = { method, headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` } };
158
+ if (body !== null) request.body = JSON.stringify(body);
159
+ return responseJson(await fetcher(`${origin}/api/site_agents/v1/dashboard${pathname}`, request));
160
+ }
161
+
155
162
  const session = {
156
163
  schema: "agentx-enterprise-session-v1",
157
164
  site_url: site.url,
@@ -201,9 +208,23 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
201
208
  const bounded = Math.max(1, Math.min(Number(limit) || 20, 100));
202
209
  return managedRequest(`runs?limit=${encodeURIComponent(Math.trunc(bounded))}`);
203
210
  },
211
+ async createDashboardPairing() {
212
+ return dashboardRequest("/pairings", { method: "POST", body: {} });
213
+ },
204
214
  async listTasks() {
205
215
  return taskRequest("");
206
216
  },
217
+ async proposeTask({ path: resourcePath, content, policyDigest, idempotencyKey } = {}) {
218
+ if (typeof resourcePath !== "string" || !resourcePath.trim()) throw enterpriseError("enterprise_task_path_required");
219
+ if (typeof content !== "string" || !content) throw enterpriseError("enterprise_task_content_required");
220
+ if (typeof policyDigest !== "string" || !policyDigest) throw enterpriseError("enterprise_task_policy_digest_required");
221
+ if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) throw enterpriseError("enterprise_task_idempotency_key_required");
222
+ return taskRequest("", {
223
+ method: "POST",
224
+ scopes: ["tasks:execute"],
225
+ body: { path: resourcePath, content, policy_digest: policyDigest, idempotency_key: idempotencyKey }
226
+ });
227
+ },
207
228
  async getTaskComparison(taskId) {
208
229
  const id = encodeURIComponent(String(taskId || ""));
209
230
  if (!id) throw enterpriseError("enterprise_task_id_required");