diff --git a/src/adapters/media-adapter.mjs b/src/adapters/media-adapter.mjs new file mode 100644 index 0000000..e51b617 --- /dev/null +++ b/src/adapters/media-adapter.mjs @@ -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 + }; +} \ No newline at end of file diff --git a/src/client.mjs b/src/client.mjs index 066b3a9..b83feba 100644 --- a/src/client.mjs +++ b/src/client.mjs @@ -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 = {}) { @@ -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 = {}) { @@ -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 = { @@ -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 = { @@ -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 = { @@ -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`, { @@ -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", { @@ -262,7 +300,7 @@ export function createMemactClient(config = {}) { categories: params.categories || [] }, connectionId: params.connection_id || config.connectionId - }) + }); }, contribute(params = {}) { return request("/v1/contributions/propose", { @@ -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", { @@ -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; +} \ No newline at end of file diff --git a/src/config-validation.mjs b/src/config-validation.mjs index 5b57e59..4f9a3ae 100644 --- a/src/config-validation.mjs +++ b/src/config-validation.mjs @@ -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 } } \ No newline at end of file diff --git a/src/index.mjs b/src/index.mjs index 5b0721f..cc35c73 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -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" \ No newline at end of file +export { parseMediaPlayback } from "./adapters/media-adapter.mjs" +export { MemactProvider, useContextClaim } from "./react.mjs" diff --git a/test/media-adapter.test.mjs b/test/media-adapter.test.mjs new file mode 100644 index 0000000..2599343 --- /dev/null +++ b/test/media-adapter.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { parseMediaPlayback } from "../src/adapters/media-adapter.mjs" + +test("parseMediaPlayback splits a raw text log string correctly into artist and track", () => { + const rawLog = "Daft Punk - One More Time"; + const result = parseMediaPlayback(rawLog); + + assert.equal(result.schema_version, "memact.ccp_observation.v1"); + assert.equal(result.category, "music"); + assert.equal(result.evidence.artist, "Daft Punk"); + assert.equal(result.evidence.track_title, "One More Time"); +}); + +test("parseMediaPlayback falls back cleanly if string format lacks standard delimiter", () => { + const rawLog = "Ambient White Noise Soundscape Loops"; + const result = parseMediaPlayback(rawLog); + + assert.equal(result.evidence.artist, "Unknown Artist"); + assert.equal(result.evidence.track_title, "Ambient White Noise Soundscape Loops"); +}); + +test("parseMediaPlayback normalizes structured Spotify webhook payload data structures", () => { + const payloadMock = { + played_at: "2026-07-12T07:30:00Z", + context_uri: "spotify:playlist:37i9dQZF1DX4sWSpwq3LiO", + track: { + name: "Starboy", + duration_ms: 230453, + artists: [{ name: "The Weeknd" }] + } + }; + + const result = parseMediaPlayback(payloadMock); + assert.equal(result.evidence.track_title, "Starboy"); + assert.equal(result.evidence.artist, "The Weeknd"); + assert.equal(result.evidence.duration_ms, 230453); + assert.equal(result.evidence.streaming_context_uri, "spotify:playlist:37i9dQZF1DX4sWSpwq3LiO"); + assert.equal(result.occurred_at, "2026-07-12T07:30:00.000Z"); +}); \ No newline at end of file diff --git a/test/sdk.test.mjs b/test/sdk.test.mjs index c20c494..f3c6760 100644 --- a/test/sdk.test.mjs +++ b/test/sdk.test.mjs @@ -1,5 +1,6 @@ -import test from "node:test" +import test, { describe } from "node:test" import assert from "node:assert/strict" +import crypto from "node:crypto" import { createMemactClient, MemactClient, MemactSDKError } from "../src/index.mjs" @@ -42,6 +43,38 @@ test("verifyAccess and runFeature post correctly", async () => { ]) }) +describe("UploadPanel E2EE Cryptographic Verification Suite (#93)", () => { + test("verifyAccess generates cryptographic signature when privateKey configuration is set", async () => { + const calls = []; + + // Generate a secure mock key-pair bundle for execution frame test isolation + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }); + + const client = createMemactClient({ + baseUrl: "https://api.example.test", + privateKey, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + }); + + await client.verifyAccess({ required_scopes: ["memory:read"] }); + + const payloadBody = JSON.parse(calls[0].options.body); + assert.ok(payloadBody.signature, "Cryptographic payload must include a signed header token"); + assert.equal(payloadBody.algo, "RSA-PSS-SHA256"); + assert.ok(payloadBody.encrypted_payload, "Should map raw strings down to signed transport contexts"); + + const parsedInnerPayload = JSON.parse(payloadBody.encrypted_payload); + assert.equal(parsedInnerPayload.required_scopes[0], "memory:read"); + assert.ok(parsedInnerPayload.timestamp, "Payload frame must bind automated verification validation timestamps"); + }); +}); + test("schema helper methods call schema endpoints", async () => { const calls = [] const client = createMemactClient({