@twin3-ai/agent-id 0.1.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,92 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const { AUTO_MANAGED_PATHS } = require("./repository-connector.js");
5
+
6
+ const SCHEMA = "agentx-signed-artifact-bundle-v0.2";
7
+ const MAX_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
8
+ const SECRET_CONTENT = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\bak_aeo_[A-Za-z0-9_-]{6,}\b|\bav_[A-Za-z0-9_-]{8,}\b|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b)/i;
9
+
10
+ function bundleError(code) {
11
+ const error = new Error(code);
12
+ error.code = code;
13
+ return error;
14
+ }
15
+ function stableValue(value) {
16
+ if (Array.isArray(value)) return value.map(stableValue);
17
+ if (!value || typeof value !== "object") return value;
18
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
19
+ }
20
+ function canonical(value) { return Buffer.from(JSON.stringify(stableValue(value)), "utf8"); }
21
+ function hash(value) { return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; }
22
+ function sameSubject(left, right) {
23
+ return ["tenant_id", "host", "environment", "agent_id"].every((field) => String(left && left[field] || "") === String(right && right[field] || ""));
24
+ }
25
+ function keyId(key) {
26
+ const publicKey = key && key.type === "public" ? key : crypto.createPublicKey(key);
27
+ const der = publicKey.export({ type: "spki", format: "der" });
28
+ return `ed25519:${crypto.createHash("sha256").update(der).digest("hex").slice(0, 24)}`;
29
+ }
30
+ function normalizeArtifacts(artifacts) {
31
+ if (!Array.isArray(artifacts) || artifacts.length === 0) throw bundleError("ARTIFACT_BUNDLE_ARTIFACTS_REQUIRED");
32
+ const seen = new Set();
33
+ return artifacts.map((artifact) => {
34
+ const path = String(artifact && artifact.path || "").replace(/^\/+/, "");
35
+ const content = artifact && artifact.content;
36
+ if (!AUTO_MANAGED_PATHS.has(path)) throw bundleError("ARTIFACT_BUNDLE_PATH_NOT_ALLOWED");
37
+ if (seen.has(path)) throw bundleError("ARTIFACT_BUNDLE_DUPLICATE_PATH");
38
+ if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > 1024 * 1024 || SECRET_CONTENT.test(content)) throw bundleError("ARTIFACT_BUNDLE_INVALID_CONTENT");
39
+ if (path.endsWith(".json")) {
40
+ try { JSON.parse(content); } catch (_error) { throw bundleError("ARTIFACT_BUNDLE_INVALID_JSON"); }
41
+ }
42
+ seen.add(path);
43
+ return {
44
+ path,
45
+ content_type: String(artifact.content_type || "text/plain; charset=utf-8").slice(0, 120),
46
+ content,
47
+ content_hash: hash(Buffer.from(content, "utf8"))
48
+ };
49
+ });
50
+ }
51
+ function unsignedBundle(bundle) {
52
+ const copy = { ...bundle };
53
+ delete copy.signature;
54
+ return copy;
55
+ }
56
+ function createArtifactBundle({ subject, artifacts, issuerPrivateKey, issuerPublicKey, source = {}, issuedAt = Math.floor(Date.now() / 1000), expiresAt = issuedAt + 3600 } = {}) {
57
+ if (!subject || !issuerPrivateKey || !issuerPublicKey) throw bundleError("ARTIFACT_BUNDLE_CONFIGURATION_INVALID");
58
+ if (!["tenant_id", "host", "environment", "agent_id"].every((field) => typeof subject[field] === "string" && subject[field])) throw bundleError("ARTIFACT_BUNDLE_SUBJECT_INVALID");
59
+ if (typeof source.run_id !== "string" || !source.run_id || typeof source.receipt_hash !== "string" || !/^sha256:[a-f0-9]{6,}$/i.test(source.receipt_hash)) throw bundleError("ARTIFACT_BUNDLE_SOURCE_INVALID");
60
+ if (!Number.isInteger(issuedAt) || !Number.isInteger(expiresAt) || expiresAt <= issuedAt || expiresAt - issuedAt > MAX_LIFETIME_SECONDS) throw bundleError("ARTIFACT_BUNDLE_TIME_INVALID");
61
+ const bundle = {
62
+ schema: SCHEMA,
63
+ bundle_version: 2,
64
+ subject: stableValue(subject),
65
+ source: { run_id: source.run_id, receipt_hash: source.receipt_hash },
66
+ issued_at: issuedAt,
67
+ expires_at: expiresAt,
68
+ issuer_key_id: keyId(issuerPublicKey),
69
+ artifacts: normalizeArtifacts(artifacts)
70
+ };
71
+ bundle.bundle_hash = hash(canonical(bundle));
72
+ bundle.signature = crypto.sign(null, canonical(bundle), issuerPrivateKey).toString("base64url");
73
+ return bundle;
74
+ }
75
+ function verifyArtifactBundle(bundle, { issuerPublicKey, expectedSubject, now = Math.floor(Date.now() / 1000) } = {}) {
76
+ if (!bundle || bundle.schema !== SCHEMA || bundle.bundle_version !== 2 || !issuerPublicKey || !bundle.signature) throw bundleError("ARTIFACT_BUNDLE_INVALID");
77
+ if (!sameSubject(bundle.subject, expectedSubject)) throw bundleError("ARTIFACT_BUNDLE_SUBJECT_MISMATCH");
78
+ if (!Number.isInteger(bundle.issued_at) || !Number.isInteger(bundle.expires_at) || now < bundle.issued_at || now >= bundle.expires_at || bundle.expires_at - bundle.issued_at > MAX_LIFETIME_SECONDS) throw bundleError("ARTIFACT_BUNDLE_EXPIRED");
79
+ if (bundle.issuer_key_id !== keyId(issuerPublicKey)) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
80
+ const artifacts = normalizeArtifacts(bundle.artifacts);
81
+ if (artifacts.some((artifact, index) => artifact.content_hash !== bundle.artifacts[index].content_hash)) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
82
+ const unsigned = unsignedBundle(bundle);
83
+ const claimedHash = unsigned.bundle_hash;
84
+ delete unsigned.bundle_hash;
85
+ if (claimedHash !== hash(canonical(unsigned))) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
86
+ const verificationKey = issuerPublicKey && issuerPublicKey.type === "public" ? issuerPublicKey : crypto.createPublicKey(issuerPublicKey);
87
+ const verified = crypto.verify(null, canonical({ ...unsigned, bundle_hash: claimedHash }), verificationKey, Buffer.from(bundle.signature, "base64url"));
88
+ if (!verified) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
89
+ return { verified: true, bundle_hash: claimedHash, subject: bundle.subject, artifacts: artifacts.map(({ content, ...item }) => item) };
90
+ }
91
+
92
+ module.exports = { SCHEMA, MAX_LIFETIME_SECONDS, createArtifactBundle, verifyArtifactBundle };
package/b1-sync.js ADDED
@@ -0,0 +1,85 @@
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 GA4_ENDPOINT = "https://analyticsdata.googleapis.com/v1beta/properties";
8
+ const AI_HOSTS = Object.freeze(["chat.openai.com", "chatgpt.com", "claude.ai", "copilot.microsoft.com", "gemini.google.com", "perplexity.ai"]);
9
+
10
+ function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
11
+ function emptyState() { return { schema: "agentx-b1-sync-state-v1", cursor: "", pending: null, seen: [], retry_at: 0, attempts: 0, next_run_at: 0, last_sync_at: 0, last_error: null }; }
12
+ function loadState(statePath) {
13
+ try { return { ...emptyState(), ...JSON.parse(fs.readFileSync(statePath, "utf8")) }; }
14
+ catch (error) { if (error.code === "ENOENT") return emptyState(); throw new Error("b1_sync_state_invalid"); }
15
+ }
16
+ function saveState(statePath, state) {
17
+ const target = path.resolve(statePath); fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
18
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
19
+ try { fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode:0o600, flag:"wx" }); fs.renameSync(temporary, target); fs.chmodSync(target, 0o600); }
20
+ finally { try { fs.rmSync(temporary, { force:true }); } catch (_error) {} }
21
+ }
22
+ function aiHost(value) {
23
+ const raw = String(value || "").trim().toLowerCase();
24
+ if (!raw || ["(direct)", "(none)", "(not set)"].includes(raw)) return "";
25
+ try {
26
+ const host = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.replace(/\.$/, "");
27
+ return AI_HOSTS.find((known) => host === known || host.endsWith(`.${known}`)) ? host : "";
28
+ } catch (_error) { return ""; }
29
+ }
30
+ function safeLabel(value) { return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(String(value || "")) ? String(value) : ""; }
31
+ function reportMap(row, dimensionHeaders, metricHeaders) {
32
+ const dimensions = Object.fromEntries(dimensionHeaders.map((name, index) => [name, String(row.dimensionValues && row.dimensionValues[index] && row.dimensionValues[index].value || "")]));
33
+ const metrics = Object.fromEntries(metricHeaders.map((name, index) => [name, String(row.metricValues && row.metricValues[index] && row.metricValues[index].value || "0")]));
34
+ return { dimensions, metrics };
35
+ }
36
+ function ga4Conversions(response, siteUrl, windowKey) {
37
+ const dimensions = (response.dimensionHeaders || []).map((item) => item && item.name).filter(Boolean);
38
+ const metrics = (response.metricHeaders || []).map((item) => item && item.name).filter(Boolean);
39
+ return (response.rows || []).flatMap((row) => {
40
+ const mapped = reportMap(row || {}, dimensions, metrics);
41
+ const source = aiHost(mapped.dimensions.sessionSource);
42
+ const count = Math.max(0, Math.floor(Number(mapped.metrics.keyEvents || 0)));
43
+ const revenue = Math.max(0, Number(mapped.metrics.totalRevenue || 0));
44
+ if (!source || count < 1 || !Number.isFinite(revenue)) return [];
45
+ const campaign = safeLabel(mapped.dimensions.sessionCampaignName);
46
+ const currency = String(response.currencyCode || response.metadata && response.metadata.currencyCode || "USD").toUpperCase();
47
+ return [{ conversion_id:`ga4_${hash(JSON.stringify([windowKey, source, campaign])).slice(0,32)}`, event:"ga4_key_event", url:siteUrl, referrer:`https://${source}/`, utm_source:source, utm_campaign:campaign, conversion_count:count, value:Number(revenue.toFixed(2)), currency:/^[A-Z]{3}$/.test(currency)?currency:"USD", timestamp:Date.parse(`${windowKey}T23:59:59Z`) }];
48
+ });
49
+ }
50
+ async function defaultFetchGa4({ token, propertyId, startDate, endDate, fetchImpl = globalThis.fetch }) {
51
+ if (typeof fetchImpl !== "function") throw new Error("ga4_fetch_unavailable");
52
+ const response = await fetchImpl(`${GA4_ENDPOINT}/${encodeURIComponent(propertyId)}:runReport`, { method:"POST", headers:{ authorization:`Bearer ${token}`, "content-type":"application/json" }, body:JSON.stringify({ dateRanges:[{startDate,endDate}], dimensions:[{name:"sessionSource"},{name:"sessionMedium"},{name:"sessionCampaignName"}], metrics:[{name:"sessions"},{name:"keyEvents"},{name:"totalRevenue"}] }), redirect:"error" });
53
+ if (!response.ok) throw new Error(`ga4_http_${response.status}`);
54
+ const body = await response.json(); if (body.error) throw new Error("ga4_provider_error"); return body;
55
+ }
56
+ function createBaseSync({ client, statePath, siteUrl, intervalMs, collect, now, random }) {
57
+ if (!client || typeof client.conversions !== "function" || !statePath || !siteUrl || typeof collect !== "function") throw new Error("b1_sync_configuration_invalid");
58
+ const url = new URL(siteUrl).origin; const state = loadState(statePath); const persist = () => saveState(statePath, state);
59
+ async function upload(batch, cursor) {
60
+ try {
61
+ const result = batch.length ? await client.conversions({ url, conversions:batch, source:"b1_automatic_sync" }) : { stored:0 };
62
+ state.pending=null; state.cursor=cursor; state.seen=[...new Set([...state.seen,...batch.map(item=>item.conversion_id)])].slice(-10000); 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();
63
+ return {ok:true,status:"synced",rows:batch.length,conversions:batch.reduce((sum,item)=>sum+Math.max(1,Number(item.conversion_count||1)),0),stored:Number(result.stored||0),cursor};
64
+ } catch (error) {
65
+ state.pending={batch:batch.slice(0,5000),cursor}; state.attempts+=1; 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||"b1_delivery_failed").slice(0,80)}; persist();
66
+ return {ok:false,status:"queued_for_retry",pending:state.pending.batch.length,retry_at:state.retry_at};
67
+ }
68
+ }
69
+ async function runOnce({force=false}={}) {
70
+ const timestamp=now(); if(!force&&state.retry_at>timestamp)return {ok:false,status:"retry_wait",retry_at:state.retry_at}; if(state.pending)return upload(state.pending.batch,state.pending.cursor); if(!force&&state.next_run_at>timestamp)return {ok:true,status:"not_due",next_run_at:state.next_run_at};
71
+ const collected=await collect({timestamp,cursor:state.cursor,seen:new Set(state.seen)}); return upload(collected.batch||[],collected.cursor||state.cursor);
72
+ }
73
+ async function status(){return {schema:state.schema,host:new URL(url).hostname,cursor:state.cursor,pending:state.pending?state.pending.batch.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};}
74
+ return Object.freeze({runOnce,status});
75
+ }
76
+ function createGa4B1Sync({client,statePath,siteUrl,propertyId,accessToken,fetchGa4=defaultFetchGa4,intervalMs=86400000,now=Date.now,random=Math.random}={}) {
77
+ if(!propertyId||!accessToken)throw new Error("ga4_b1_configuration_invalid");
78
+ return createBaseSync({client,statePath,siteUrl,intervalMs,now,random,collect:async({timestamp,cursor})=>{const date=new Date(timestamp-86400000).toISOString().slice(0,10);if(cursor===date)return {batch:[],cursor};const response=await fetchGa4({token:accessToken,propertyId,startDate:date,endDate:date});return {batch:ga4Conversions(response,new URL(siteUrl).origin,date),cursor:date};}});
79
+ }
80
+ function createOrderB1Sync({client,statePath,siteUrl,sourceId,loadOrders,intervalMs=3600000,now=Date.now,random=Math.random}={}) {
81
+ if(!safeLabel(sourceId)||typeof loadOrders!=="function")throw new Error("order_b1_configuration_invalid");
82
+ return createBaseSync({client,statePath,siteUrl,intervalMs,now,random,collect:async({timestamp,seen})=>{const rows=await loadOrders();const batch=(Array.isArray(rows)?rows:[]).flatMap((row)=>{const source=aiHost(row&&row.source);const rawId=String(row&&row.order_id||"");const receipt=rawId?`order_${hash(`${sourceId}:${rawId}`).slice(0,32)}`:"";const amount=Number(row&&row.amount||0);if(!source||!receipt||seen.has(receipt)||!Number.isFinite(amount)||amount<0)return [];return [{conversion_id:receipt,event:safeLabel(row.event)||"order",url:new URL(siteUrl).origin,referrer:`https://${source}/`,value:Number(amount.toFixed(2)),currency:/^[A-Z]{3}$/.test(String(row.currency||"").toUpperCase())?String(row.currency).toUpperCase():"USD",timestamp:Number(row.timestamp||timestamp)}];});return {batch,cursor:String(timestamp)};}});
83
+ }
84
+
85
+ module.exports={createGa4B1Sync,createOrderB1Sync,ga4Conversions,defaultFetchGa4};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { createSiteAgentClient } = require("../site-agent.js");
7
+ const { createGa4B1Sync, createOrderB1Sync } = require("../b1-sync.js");
8
+
9
+ function valueAfter(args, name, fallback = "") { const index=args.indexOf(name); return index>=0&&args[index+1]?args[index+1]:fallback; }
10
+ function help() {
11
+ console.log(`agent-id-b1-sync
12
+
13
+ Usage:
14
+ agent-id-b1-sync --ga4 --url https://example.com
15
+ agent-id-b1-sync --orders ./crm-orders.json --source-id crm-main --url https://example.com
16
+
17
+ Shared environment:
18
+ SITE_AGENT_KEY Agent ID Site Agent Key
19
+ AGENT_ID_ENDPOINT Parent Agent ID origin
20
+ AGENT_ID_B1_STATE Local cursor and retry state path
21
+
22
+ GA4 environment:
23
+ GA4_PROPERTY_ID GA4 numeric property ID
24
+ GA4_ACCESS_TOKEN Read-only Analytics Data API OAuth token
25
+ `);
26
+ }
27
+ async function main() {
28
+ const args=process.argv.slice(2); if(args.includes("--help")||args.includes("-h"))return help();
29
+ const siteUrl=valueAfter(args,"--url",process.env.SITE_URL||""); if(!siteUrl)throw new Error("--url or SITE_URL is required");
30
+ const endpoint=valueAfter(args,"--endpoint",process.env.AGENT_ID_ENDPOINT||"");
31
+ const statePath=valueAfter(args,"--state",process.env.AGENT_ID_B1_STATE||path.join(process.cwd(),".agent-id","b1-sync-state.json"));
32
+ const client=createSiteAgentClient({endpoint,agentKey:process.env.SITE_AGENT_KEY});
33
+ let sync;
34
+ if(args.includes("--ga4")) {
35
+ sync=createGa4B1Sync({client,statePath,siteUrl,propertyId:process.env.GA4_PROPERTY_ID,accessToken:process.env.GA4_ACCESS_TOKEN});
36
+ } else {
37
+ const orderPath=valueAfter(args,"--orders",""); const sourceId=valueAfter(args,"--source-id","");
38
+ if(!orderPath||!sourceId)throw new Error("--ga4 or both --orders and --source-id are required");
39
+ sync=createOrderB1Sync({client,statePath,siteUrl,sourceId,loadOrders:async()=>JSON.parse(fs.readFileSync(path.resolve(orderPath),"utf8"))});
40
+ }
41
+ const result=args.includes("--status")?await sync.status():await sync.runOnce({force:args.includes("--force")});
42
+ console.log(JSON.stringify(result,null,2));
43
+ }
44
+ main().catch((error)=>{console.error(error&&error.message?error.message:"b1_sync_failed");process.exit(1);});
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const { createSiteAgentClient } = require("../site-agent.js");
6
+ const { createCloudflareA1Sync } = require("../cloudflare-a1-sync.js");
7
+
8
+ function valueAfter(args, name, fallback = "") {
9
+ const index = args.indexOf(name);
10
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
11
+ }
12
+ function help() {
13
+ console.log(`agent-id-cloudflare-a1
14
+
15
+ Usage:
16
+ agent-id-cloudflare-a1 --url https://example.com --once
17
+ agent-id-cloudflare-a1 --url https://example.com --status
18
+
19
+ Required environment:
20
+ SITE_AGENT_KEY Agent ID Site Agent Key
21
+ CLOUDFLARE_API_TOKEN Read-only Cloudflare Analytics token
22
+ CLOUDFLARE_ZONE_ID Website zone ID
23
+
24
+ Optional environment:
25
+ AGENT_ID_ENDPOINT Parent Agent ID origin
26
+ AGENT_ID_A1_STATE Local cursor and retry state path
27
+ AGENT_ID_A1_INTERVAL_MS Minimum interval between successful syncs
28
+ `);
29
+ }
30
+ async function main() {
31
+ const args = process.argv.slice(2);
32
+ if (args.includes("--help") || args.includes("-h")) return help();
33
+ const siteUrl = valueAfter(args, "--url", process.env.SITE_URL || "");
34
+ const endpoint = valueAfter(args, "--endpoint", process.env.AGENT_ID_ENDPOINT || "");
35
+ const statePath = valueAfter(args, "--state", process.env.AGENT_ID_A1_STATE || path.join(process.cwd(), ".agent-id", "cloudflare-a1-state.json"));
36
+ if (!siteUrl) throw new Error("--url or SITE_URL is required");
37
+ const client = createSiteAgentClient({ endpoint, agentKey: process.env.SITE_AGENT_KEY });
38
+ const sync = createCloudflareA1Sync({
39
+ client,
40
+ statePath,
41
+ siteUrl,
42
+ zoneId: process.env.CLOUDFLARE_ZONE_ID,
43
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
44
+ intervalMs: Number(process.env.AGENT_ID_A1_INTERVAL_MS || 900000)
45
+ });
46
+ const result = args.includes("--status") ? await sync.status() : await sync.runOnce({ force: args.includes("--force") });
47
+ console.log(JSON.stringify(result, null, 2));
48
+ }
49
+
50
+ main().catch((error) => {
51
+ console.error(error && error.message ? error.message : "cloudflare_a1_sync_failed");
52
+ process.exit(1);
53
+ });