Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/adapters/media-adapter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Media Playback Context Adapter
* Normalizes raw playback histories into unified CCP music signals.
*/

/**
* Parses a raw media history log string or a telemetry track frame object.
* @param {Object|string} rawPlayback - Input media playback detail data
* @returns {Object} Standardized CCP context metadata payload
*/
export function parseMediaPlayback(rawPlayback) {
let trackName = "Unknown Track";
let artistName = "Unknown Artist";
let contextUri = "";
let durationMs = 0;
let timestamp = new Date().toISOString();

// Support parsing either a direct raw track title string or structured JSON telemetry
if (typeof rawPlayback === "string") {
const parts = rawPlayback.split(" - ");
if (parts.length >= 2) {
artistName = parts[0].trim();
trackName = parts[1].trim();
} else {
trackName = rawPlayback.trim();
}
} else if (rawPlayback && typeof rawPlayback === "object") {
// Extract property variables matching standard media streaming webhook structures (e.g. Spotify)
trackName = rawPlayback.track_name || rawPlayback.track?.name || trackName;
artistName = rawPlayback.artist_name || rawPlayback.track?.artists?.[0]?.name || artistName;
contextUri = rawPlayback.context_uri || rawPlayback.context?.uri || "";
durationMs = Number(rawPlayback.duration_ms || rawPlayback.track?.duration_ms || 0);

if (rawPlayback.played_at || rawPlayback.timestamp) {
timestamp = new Date(rawPlayback.played_at || rawPlayback.timestamp).toISOString();
}
}

return {
schema_version: "memact.ccp_observation.v1",
event_type: "media_playback",
category: "music",
title: `Listened to ${trackName} by ${artistName}`,
evidence: {
track_title: trackName,
artist: artistName,
duration_ms: durationMs,
streaming_context_uri: contextUri
},
occurred_at: timestamp
};
}
169 changes: 104 additions & 65 deletions src/client.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MemactSDKError, logDeprecationWarning } from "./errors.mjs"
import { validateCaptureEvent } from "./local-validation.mjs"
import { validateClientConfig } from "./config-validation.mjs"
import crypto from "node:crypto";
import { MemactSDKError, logDeprecationWarning } from "./errors.mjs";
import { validateCaptureEvent } from "./local-validation.mjs";
import { validateClientConfig } from "./config-validation.mjs";

export class MemactClient {
static async connect(identityAddress, config = {}) {
Expand Down Expand Up @@ -82,36 +83,36 @@ export class MemactClient {
}

export function createMemactClient(config = {}) {
const validation = validateClientConfig(config)
const validation = validateClientConfig(config);
if (!validation.ok) {
throw new MemactSDKError(validation.errors.join(", "), { code: "invalid_client_config" })
throw new MemactSDKError(validation.errors.join(", "), { code: "invalid_client_config" });
}

const baseUrl = String(config.baseUrl || "").replace(/\/+$/, "")
if (!baseUrl) throw new MemactSDKError("baseUrl is required", { code: "missing_base_url" })
const fetchImpl = config.fetchImpl || globalThis.fetch
if (typeof fetchImpl !== "function") throw new MemactSDKError("fetch is not available", { code: "missing_fetch" })
const baseUrl = String(config.baseUrl || "").replace(/\/+$/, "");
if (!baseUrl) throw new MemactSDKError("baseUrl is required", { code: "missing_base_url" });
const fetchImpl = config.fetchImpl || globalThis.fetch;
if (typeof fetchImpl !== "function") throw new MemactSDKError("fetch is not available", { code: "missing_fetch" });

const request = async (path, { method = "GET", body, connectionId = config.connectionId } = {}) => {
const headers = { "Content-Type": "application/json" }
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`
if (connectionId) headers["X-Memact-Connection-Id"] = connectionId
const headers = { "Content-Type": "application/json" };
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
if (connectionId) headers["X-Memact-Connection-Id"] = connectionId;
const response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
})
const text = await response.text()
const payload = text ? JSON.parse(text) : {}
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new MemactSDKError(payload?.error?.message || "Memact request failed", {
status: response.status,
code: payload?.error?.code || "memact_request_failed",
details: payload
})
});
}
return payload
}
return payload;
};

const client = {
capture(event = {}) {
Expand All @@ -121,10 +122,10 @@ export function createMemactClient(config = {}) {
app_id: event.app_id || config.appId,
occurred_at: event.occurred_at || new Date().toISOString(),
...event
}
const validation = validateCaptureEvent(body)
if (!validation.ok) throw new MemactSDKError(validation.errors.join(", "), { code: "invalid_capture_event" })
return request("/v1/capture/events", { method: "POST", body, connectionId: event.connection_id || config.connectionId })
};
const validation = validateCaptureEvent(body);
if (!validation.ok) throw new MemactSDKError(validation.errors.join(", "), { code: "invalid_capture_event" });
return request("/v1/capture/events", { method: "POST", body, connectionId: event.connection_id || config.connectionId });
},
sendSignal(signal = {}, options = {}) {
const body = {
Expand All @@ -135,12 +136,12 @@ export function createMemactClient(config = {}) {
occurred_at: signal.occurred_at || new Date().toISOString(),
...signal
}
}
return request("/v1/wiki/proposals", { method: "POST", body, connectionId: body.connection_id })
};
return request("/v1/wiki/proposals", { method: "POST", body, connectionId: body.connection_id });
},
sendAppActivity(activity = {}, options = {}) {
logDeprecationWarning("sendAppActivity", "sendSignal");
return this.sendSignal(activity, options)
return this.sendSignal(activity, options);
},
proposeContext(proposal = {}, options = {}) {
const body = {
Expand All @@ -149,8 +150,8 @@ export function createMemactClient(config = {}) {
source_app: proposal.source_app || config.appId || "app",
...proposal
}
}
return request("/v1/wiki/proposals", { method: "POST", body, connectionId: body.connection_id })
};
return request("/v1/wiki/proposals", { method: "POST", body, connectionId: body.connection_id });
},
suggestMemory(proposal = {}, options = {}) {
const body = {
Expand All @@ -159,23 +160,60 @@ export function createMemactClient(config = {}) {
source_app: proposal.source_app || config.appId || "app",
...proposal
}
}
return request("/v1/memory/suggestions", { method: "POST", body, connectionId: body.connection_id })
};
return request("/v1/memory/suggestions", { method: "POST", body, connectionId: body.connection_id });
},
proposeWikiEntry(proposal = {}, options = {}) {
logDeprecationWarning("proposeWikiEntry", "suggestMemory");
return this.suggestMemory(proposal, options)
return this.suggestMemory(proposal, options);
},
proposeSuggestion(proposal = {}, options = {}) {
logDeprecationWarning("proposeSuggestion", "suggestMemory");
return this.suggestMemory(proposal, options)
return this.suggestMemory(proposal, options);
},
proposeContextSuggestion(proposal = {}, options = {}) {
logDeprecationWarning("proposeContextSuggestion", "proposeContext");
return this.proposeContext(proposal, options)
return this.proposeContext(proposal, options);
},
verifyAccess(options = {}) {
return request("/v1/access/verify", { method: "POST", body: options, connectionId: options.connection_id || config.connectionId })
let outboundBody = { ...options };

// FIXED (#93): Asymmetric signing wrapper to support core E2EE verification protocol
if (config.privateKey) {
const timestamp = new Date().toISOString();
const payloadToSign = JSON.stringify({
...options,
timestamp,
connection_id: options.connection_id || config.connectionId
});

try {
const signature = crypto.sign(
"sha256",
Buffer.from(payloadToSign),
{
key: config.privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
}
).toString("base64");

outboundBody = {
encrypted_payload: payloadToSign,
signature,
algo: "RSA-PSS-SHA256"
};
} catch (err) {
throw new MemactSDKError(`Cryptographic E2EE wrapping failed: ${err.message}`, {
code: "crypto_verification_failed"
});
}
}

return request("/v1/access/verify", {
method: "POST",
body: outboundBody,
connectionId: options.connection_id || config.connectionId
});
},
runFeature(featureId, input = {}, options = {}) {
return request(`/v1/features/${encodeURIComponent(featureId)}/run`, {
Expand All @@ -186,69 +224,69 @@ export function createMemactClient(config = {}) {
input
},
connectionId: options.connection_id || config.connectionId
})
});
},
getFeatures() {
return request("/v1/features")
return request("/v1/features");
},
listContext(options = {}) {
return request(withQuery("/v1/context", options), { connectionId: options.connection_id || config.connectionId })
return request(withQuery("/v1/context", options), { connectionId: options.connection_id || config.connectionId });
},
getContextCategories(options = {}) {
return request(withQuery("/v1/context", options), { connectionId: options.connection_id || config.connectionId })
return request(withQuery("/v1/context", options), { connectionId: options.connection_id || config.connectionId });
},
addContextCategory(context = {}, options = {}) {
return request("/v1/context", {
method: "POST",
body: context,
connectionId: options.connection_id || config.connectionId
})
});
},
addSubContext(contextId, subContext = {}, options = {}) {
if (!contextId) throw new MemactSDKError("contextId is required", { code: "missing_context_id" })
if (!contextId) throw new MemactSDKError("contextId is required", { code: "missing_context_id" });
return request(`/v1/context/${encodeURIComponent(contextId)}/subcontexts`, {
method: "POST",
body: subContext,
connectionId: options.connection_id || config.connectionId
})
});
},
getContext(contextId, options = {}) {
if (!contextId) throw new MemactSDKError("contextId is required", { code: "missing_context_id" })
return request(withQuery(`/v1/context/${encodeURIComponent(contextId)}`, options), { connectionId: options.connection_id || config.connectionId })
if (!contextId) throw new MemactSDKError("contextId is required", { code: "missing_context_id" });
return request(withQuery(`/v1/context/${encodeURIComponent(contextId)}`, options), { connectionId: options.connection_id || config.connectionId });
},
getSchemas(options = {}) {
return request(withQuery("/v1/schemas", options), { connectionId: options.connection_id || config.connectionId })
return request(withQuery("/v1/schemas", options), { connectionId: options.connection_id || config.connectionId });
},
listSchemas(options = {}) {
return request(withQuery("/v1/schemas", options), { connectionId: options.connection_id || config.connectionId })
return request(withQuery("/v1/schemas", options), { connectionId: options.connection_id || config.connectionId });
},
addSchema(schema = {}, options = {}) {
return request("/v1/schemas", {
method: "POST",
body: schema,
connectionId: options.connection_id || config.connectionId
})
});
},
addSubSchema(schemaId, subSchema = {}, options = {}) {
if (!schemaId) throw new MemactSDKError("schemaId is required", { code: "missing_schema_id" })
if (!schemaId) throw new MemactSDKError("schemaId is required", { code: "missing_schema_id" });
return request(`/v1/schemas/${encodeURIComponent(schemaId)}/subschemas`, {
method: "POST",
body: subSchema,
connectionId: options.connection_id || config.connectionId
})
});
},
getSchema(schemaId, options = {}) {
if (!schemaId) throw new MemactSDKError("schemaId is required", { code: "missing_schema_id" })
return request(withQuery(`/v1/schemas/${encodeURIComponent(schemaId)}`, options), { connectionId: options.connection_id || config.connectionId })
if (!schemaId) throw new MemactSDKError("schemaId is required", { code: "missing_schema_id" });
return request(withQuery(`/v1/schemas/${encodeURIComponent(schemaId)}`, options), { connectionId: options.connection_id || config.connectionId });
},
getMemory(options = {}) {
return request(withQuery("/v1/memory", options), { connectionId: options.connection_id || config.connectionId })
return request(withQuery("/v1/memory", options), { connectionId: options.connection_id || config.connectionId });
},
getAllowedMemory(options = {}) {
return this.getMemory(options)
return this.getMemory(options);
},
getCredits() {
return request("/v1/credits")
return request("/v1/credits");
},
requestContext(params = {}) {
return request("/v1/cap/request", {
Expand All @@ -262,7 +300,7 @@ export function createMemactClient(config = {}) {
categories: params.categories || []
},
connectionId: params.connection_id || config.connectionId
})
});
},
contribute(params = {}) {
return request("/v1/contributions/propose", {
Expand All @@ -276,9 +314,10 @@ export function createMemactClient(config = {}) {
connection_id: params.connection_id || config.connectionId
},
connectionId: params.connection_id || config.connectionId
})
});
}
}
};

client.cap = {
request(capRequest = {}, options = {}) {
return request("/v1/cap/request", {
Expand All @@ -288,17 +327,17 @@ export function createMemactClient(config = {}) {
...capRequest
},
connectionId: options.connection_id || capRequest.connection_id || config.connectionId
})
});
}
}
return client
}
};

function withQuery(path, options = {}) {
const params = new URLSearchParams()
if (options.connection_id) params.set("connection_id", options.connection_id)
if (Array.isArray(options.activity_categories)) params.set("activity_categories", options.activity_categories.join(","))
const query = params.toString()
return query ? `${path}?${query}` : path
return client;
}

function withQuery(path, options = {}) {
const params = new URLSearchParams();
if (options.connection_id) params.set("connection_id", options.connection_id);
if (Array.isArray(options.activity_categories)) params.set("activity_categories", options.activity_categories.join(","));
const query = params.toString();
return query ? `${path}?${query}` : path;
}
5 changes: 5 additions & 0 deletions src/config-validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ export function validateClientConfig(config = {}) {
errors.push("connectionId must be a string when provided")
}

// FIXED (#93): Support E2EE configuration by validating optional cryptographic keys
if (config.privateKey !== undefined && typeof config.privateKey !== "string") {
errors.push("privateKey must be a string when provided")
}

return { ok: errors.length === 0, errors }
}
3 changes: 2 additions & 1 deletion src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createMemactClient, MemactClient } from "./client.mjs"
export { MemactSDKError } from "./errors.mjs"
export { validateClientConfig } from "./config-validation.mjs"
export { MemactProvider, useContextClaim } from "./react.mjs"
export { parseMediaPlayback } from "./adapters/media-adapter.mjs"
export { MemactProvider, useContextClaim } from "./react.mjs"
Loading