diff --git a/package-lock.json b/package-lock.json
index 0242a24..9d4e74d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "graphiti-pubky-extension",
"version": "1.0.0",
"dependencies": {
- "@synonymdev/pubky": "latest",
+ "@synonymdev/pubky": "^0.5.4",
+ "@types/dompurify": "^3.0.5",
"dom-anchor-text-position": "^5.0.0",
"dom-anchor-text-quote": "^4.0.2",
+ "dompurify": "^3.3.1",
"pubky-app-specs": "^0.4.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",
@@ -1534,6 +1536,15 @@
"@types/har-format": "*"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1614,6 +1625,12 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2771,6 +2788,15 @@
"integrity": "sha512-1strSwd201Gfhfkfsk77SX9xyJGzu12gqUo5Q0W3Njtj2QxcfQTwCDOynZ6npZ4ASUFRQq0asjYDRlFxYPKwTA==",
"license": "MIT"
},
+ "node_modules/dompurify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
+ "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3251,9 +3277,9 @@
}
},
"node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
diff --git a/package.json b/package.json
index c9296d9..aa1a4fa 100644
--- a/package.json
+++ b/package.json
@@ -21,9 +21,11 @@
"test:e2e:errors": "node run-e2e-with-error-capture.js"
},
"dependencies": {
- "@synonymdev/pubky": "latest",
+ "@synonymdev/pubky": "^0.5.4",
+ "@types/dompurify": "^3.0.5",
"dom-anchor-text-position": "^5.0.0",
"dom-anchor-text-quote": "^4.0.2",
+ "dompurify": "^3.3.1",
"pubky-app-specs": "^0.4.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",
@@ -31,6 +33,7 @@
"react-image-crop": "^10.1.8"
},
"devDependencies": {
+ "@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/chrome": "^0.0.268",
@@ -47,7 +50,6 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3",
"vite": "^5.3.3",
- "vitest": "^1.3.1",
- "@playwright/test": "^1.40.0"
+ "vitest": "^1.3.1"
}
}
diff --git a/src/content/AnnotationManager.ts b/src/content/AnnotationManager.ts
index 1a34b5e..1f8e1ce 100644
--- a/src/content/AnnotationManager.ts
+++ b/src/content/AnnotationManager.ts
@@ -10,6 +10,7 @@ import {
} from '../utils/validation';
import { ANNOTATION_CONSTANTS, MESSAGE_TYPES, TIMING_CONSTANTS, UI_CONSTANTS } from '../utils/constants';
import { isHTMLButtonElement } from '../utils/type-guards';
+import DOMPurify from 'dompurify';
/**
* Annotation data structure
@@ -460,12 +461,14 @@ export class AnnotationManager {
const button = document.createElement('button');
button.className = 'pubky-annotation-button';
- button.innerHTML = `
+ // Use DOMPurify to sanitize HTML (defense-in-depth, even though this is static)
+ const buttonHtml = `
Add Annotation
`;
+ button.innerHTML = DOMPurify.sanitize(buttonHtml);
// Position button to the right and slightly below the selection
// Account for button width (approximately 140px) and add some padding
@@ -572,7 +575,8 @@ export class AnnotationManager {
modal.className = 'pubky-annotation-modal';
modal.onclick = (e) => e.stopPropagation();
- modal.innerHTML = `
+ // Sanitize modal HTML with DOMPurify
+ const modalHtml = `
Add Annotation
"${this.escapeHtml(this.currentSelection.text)}"
@@ -585,6 +589,7 @@ export class AnnotationManager {
`;
+ modal.innerHTML = DOMPurify.sanitize(modalHtml);
const textarea = modal.querySelector('textarea')!;
const cancelBtn = modal.querySelector('.cancel-btn')!;
@@ -920,6 +925,7 @@ export class AnnotationManager {
private handleHighlightClick(annotation: Annotation) {
logger.info('ContentScript', 'Highlight clicked', { id: annotation.id });
+ // Highlight the clicked annotation on the page
document.querySelectorAll(`.${this.activeHighlightClass}`).forEach((el) => {
el.classList.remove(this.activeHighlightClass);
});
@@ -927,11 +933,21 @@ export class AnnotationManager {
const highlight = document.querySelector(`[data-annotation-id="${annotation.id}"]`);
if (highlight) {
highlight.classList.add(this.activeHighlightClass);
+ // Scroll the highlight into view
+ highlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
+ // Send message to background to open sidepanel
+ // Must be synchronous to preserve user gesture context
chrome.runtime.sendMessage({
- type: 'SHOW_ANNOTATION',
+ type: 'OPEN_SIDE_PANEL_FOR_ANNOTATION',
annotationId: annotation.id,
+ }, () => {
+ if (chrome.runtime.lastError) {
+ logger.warn('ContentScript', 'Failed to send open sidepanel message', new Error(chrome.runtime.lastError.message));
+ } else {
+ logger.info('ContentScript', 'Sidepanel open requested', { annotationId: annotation.id });
+ }
});
}
diff --git a/src/content/DrawingManager.ts b/src/content/DrawingManager.ts
index d854109..08eed77 100644
--- a/src/content/DrawingManager.ts
+++ b/src/content/DrawingManager.ts
@@ -2,6 +2,7 @@ import { contentLogger as logger } from './logger';
import { compressCanvas, getRecommendedQuality, formatBytes } from '../utils/image-compression';
import { DRAWING_CONSTANTS, DRAWING_UI_CONSTANTS, MESSAGE_TYPES, UI_CONSTANTS } from '../utils/constants';
import { isHTMLInputElement } from '../utils/type-guards';
+import DOMPurify from 'dompurify';
export class DrawingManager {
private canvas: HTMLCanvasElement | null = null;
@@ -202,7 +203,7 @@ export class DrawingManager {
backdrop-filter: blur(10px);
`;
- this.toolbar.innerHTML = `
+ const toolbarHtml = `
Graphiti Drawing
@@ -284,6 +285,7 @@ export class DrawingManager {
">Save
`;
+ this.toolbar.innerHTML = DOMPurify.sanitize(toolbarHtml);
document.body.appendChild(this.toolbar);
diff --git a/src/content/PubkyURLHandler.ts b/src/content/PubkyURLHandler.ts
index 3a5e12a..bd0b341 100644
--- a/src/content/PubkyURLHandler.ts
+++ b/src/content/PubkyURLHandler.ts
@@ -1,4 +1,5 @@
import { contentLogger as logger } from './logger';
+import DOMPurify from 'dompurify';
export class PubkyURLHandler {
constructor() {
@@ -165,10 +166,12 @@ export class PubkyURLHandler {
const normalizedUrl = url.replace(/^pubky:(?!\/\/)/, 'pubky://');
button.setAttribute('data-pubky-url', normalizedUrl);
- button.innerHTML = `
+ // Sanitize button HTML with DOMPurify (synchronous import)
+ const buttonHtml = `
🔗
${url.length > 30 ? url.substring(0, 30) + '...' : url}
`;
+ button.innerHTML = DOMPurify.sanitize(buttonHtml);
fragments.push(button);
lastIndex = matchIndex + url.length;
diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts
index 45a78c7..8ee1573 100644
--- a/src/offscreen/offscreen.ts
+++ b/src/offscreen/offscreen.ts
@@ -54,8 +54,8 @@ class OffscreenHandler {
try {
console.log('[Graphiti Offscreen] Initializing Pubky client...');
- const { Client } = await import('@synonymdev/pubky');
- this.client = new Client();
+ const { getPubkyClientAsync } = await import('../utils/pubky-client-factory');
+ this.client = await getPubkyClientAsync();
this.isInitialized = true;
console.log('[Graphiti Offscreen] Pubky client initialized successfully');
@@ -287,7 +287,14 @@ class OffscreenHandler {
const allAnnotations = await annotationStorage.getAllAnnotations();
for (const url in allAnnotations) {
for (const annotation of allAnnotations[url]) {
- if (!annotation.postUri && annotation.author) {
+ // Sync if: no postUri AND (has author OR we can assign current session as author)
+ if (!annotation.postUri) {
+ // If annotation was created while logged out, assign current session as author
+ if (!annotation.author || annotation.author === '') {
+ annotation.author = session.pubky;
+ await annotationStorage.saveAnnotation(annotation);
+ }
+
const result = await this.syncAnnotation({
url: annotation.url,
selectedText: annotation.selectedText,
diff --git a/src/profile/profile-renderer.ts b/src/profile/profile-renderer.ts
index 1b972dc..260d0d2 100644
--- a/src/profile/profile-renderer.ts
+++ b/src/profile/profile-renderer.ts
@@ -4,6 +4,7 @@
*/
import { imageHandler } from '../utils/image-handler';
+import DOMPurify from 'dompurify';
// Inline logger for profile renderer
class ProfileRendererLogger {
@@ -76,9 +77,9 @@ class ProfileRenderer {
private async initializePubky() {
try {
- const { Client } = await import('@synonymdev/pubky');
- this.pubky = new Client();
- rendererLogger.info('Pubky Client initialized');
+ const { getPubkyClientAsync } = await import('../utils/pubky-client-factory');
+ this.pubky = await getPubkyClientAsync();
+ rendererLogger.info('Pubky Client initialized via singleton');
} catch (error) {
rendererLogger.error('Failed to initialize Pubky Client', error);
throw new Error('Failed to initialize Pubky client');
@@ -255,7 +256,7 @@ class ProfileRenderer {
}
}
- private showContent(html: string, title: string) {
+ private async showContent(html: string, title: string) {
// Update document title
document.title = title;
@@ -263,8 +264,8 @@ class ProfileRenderer {
this.loadingEl.classList.add('hidden');
this.errorEl.classList.add('hidden');
- // Show content
- this.contentEl.innerHTML = html;
+ // Sanitize HTML from homeserver before displaying (critical security fix)
+ this.contentEl.innerHTML = DOMPurify.sanitize(html);
this.contentEl.classList.remove('hidden');
}
diff --git a/src/utils/auth-sdk.ts b/src/utils/auth-sdk.ts
index 232d097..7fae3c4 100644
--- a/src/utils/auth-sdk.ts
+++ b/src/utils/auth-sdk.ts
@@ -1,6 +1,7 @@
import { logger } from './logger';
import { storage, Session } from './storage';
import { profileManager } from './profile-manager';
+import { getPubkyClientAsync } from './pubky-client-factory';
/**
* Pubky authentication using official @synonymdev/pubky SDK
@@ -25,7 +26,21 @@ class AuthManagerSDK {
private currentAuthRequest: AuthRequest | null = null;
private constructor() {
- this.initializePubky();
+ // Client will be initialized lazily via ensureClient()
+ }
+
+ /**
+ * Validate capabilities format before use
+ */
+ private validateCapabilities(capabilities: string): string {
+ if (!capabilities || typeof capabilities !== 'string') {
+ throw new Error('Capabilities must be a non-empty string');
+ }
+ // Validate format - must start with /pub/
+ if (!capabilities.startsWith('/pub/')) {
+ throw new Error('Capabilities must start with /pub/');
+ }
+ return capabilities;
}
static getInstance(): AuthManagerSDK {
@@ -36,28 +51,14 @@ class AuthManagerSDK {
}
/**
- * Initialize Pubky client
- */
- private async initializePubky() {
- try {
- // Dynamic import to handle the SDK
- const { Client } = await import('@synonymdev/pubky');
- this.client = new Client();
- logger.info('AuthSDK', 'Pubky Client initialized');
- } catch (error) {
- logger.error('AuthSDK', 'Failed to initialize Pubky Client', error as Error);
- throw error;
- }
- }
-
- /**
- * Ensure Client is initialized
+ * Ensure Client is initialized using singleton factory
*/
private async ensureClient(): Promise
{
if (!this.client) {
- await this.initializePubky();
+ this.client = await getPubkyClientAsync();
+ logger.info('AuthSDK', 'Pubky Client initialized via singleton');
}
- return this.client!;
+ return this.client;
}
/**
@@ -69,8 +70,11 @@ class AuthManagerSDK {
const client = await this.ensureClient();
- // Create auth request with capabilities
- this.currentAuthRequest = client.authRequest(RELAY_URL, REQUIRED_CAPABILITIES);
+ // Validate capabilities before creating auth request
+ const validatedCapabilities = this.validateCapabilities(REQUIRED_CAPABILITIES);
+
+ // Create auth request with validated capabilities
+ this.currentAuthRequest = client.authRequest(RELAY_URL, validatedCapabilities);
// Get authorization URL (pubkyauth:// URL)
const authUrl = this.currentAuthRequest.url();
diff --git a/src/utils/image-handler.ts b/src/utils/image-handler.ts
index 896b6eb..9e0e2fd 100644
--- a/src/utils/image-handler.ts
+++ b/src/utils/image-handler.ts
@@ -24,8 +24,8 @@ export class ImageHandler {
private async ensureClient(): Promise {
if (!this.client) {
- const { Client } = await import('@synonymdev/pubky');
- this.client = new Client();
+ const { getPubkyClientAsync } = await import('./pubky-client-factory');
+ this.client = await getPubkyClientAsync();
}
return this.client;
}
diff --git a/src/utils/profile-manager.ts b/src/utils/profile-manager.ts
index f63db0b..b864eed 100644
--- a/src/utils/profile-manager.ts
+++ b/src/utils/profile-manager.ts
@@ -28,12 +28,12 @@ export class ProfileManager {
}
/**
- * Initialize the Pubky client
+ * Initialize the Pubky client using singleton factory
*/
private async ensureClient(): Promise {
if (!this.client) {
- const { Client } = await import('@synonymdev/pubky');
- this.client = new Client();
+ const { getPubkyClientAsync } = await import('./pubky-client-factory');
+ this.client = await getPubkyClientAsync();
}
return this.client;
}
diff --git a/src/utils/pubky-api-sdk.ts b/src/utils/pubky-api-sdk.ts
index a3758b1..a7253ce 100644
--- a/src/utils/pubky-api-sdk.ts
+++ b/src/utils/pubky-api-sdk.ts
@@ -49,24 +49,15 @@ class PubkyAPISDK {
}
try {
- // Dynamic import with error handling - the package itself might access window
- const pubkyModule = await import('@synonymdev/pubky').catch((importError) => {
- // If import fails due to window issues, wrap the error
- if (importError.message?.includes('window') || !this.isClientContextAvailable()) {
- throw new Error('Pubky Client requires window object (not available in service workers)');
- }
- throw importError;
- });
-
- const { Client } = pubkyModule;
-
- // Check window again before creating Client (it might access window in constructor)
+ // Check window before getting Client (it might access window in constructor)
if (!this.isClientContextAvailable()) {
throw new Error('Pubky Client requires window object (not available in service workers)');
}
- this.pubky = new Client();
- logger.info('PubkyAPISDK', 'Pubky Client initialized');
+ // Use singleton factory instead of creating new instance
+ const { getPubkyClientAsync } = await import('./pubky-client-factory');
+ this.pubky = await getPubkyClientAsync();
+ logger.info('PubkyAPISDK', 'Pubky Client initialized via singleton');
} catch (error) {
// Use console directly to avoid circular logger issues
console.error('[PubkyAPISDK] Failed to initialize Pubky Client', error);
diff --git a/src/utils/pubky-client-factory.ts b/src/utils/pubky-client-factory.ts
new file mode 100644
index 0000000..464f93b
--- /dev/null
+++ b/src/utils/pubky-client-factory.ts
@@ -0,0 +1,65 @@
+/**
+ * @fileoverview Pubky Client singleton factory
+ *
+ * Provides a single shared instance of the Pubky Client across the extension
+ * to prevent memory leaks and ensure consistent state.
+ */
+
+import { logger } from './logger';
+
+type Client = any;
+
+let clientInstance: Client | null = null;
+
+let initializationPromise: Promise | null = null;
+
+/**
+ * Get or create the singleton Pubky Client instance
+ * Note: This is synchronous but may return null if not yet initialized
+ * Use getPubkyClientAsync() for guaranteed initialization
+ * @returns The shared Pubky Client instance or null if not initialized
+ */
+export function getPubkyClient(): Client | null {
+ return clientInstance;
+}
+
+/**
+ * Initialize the Pubky Client singleton
+ * Call this before using getPubkyClient() if you need synchronous access
+ */
+export async function initializePubkyClient(): Promise {
+ if (clientInstance) {
+ return clientInstance;
+ }
+
+ if (initializationPromise) {
+ return initializationPromise;
+ }
+
+ initializationPromise = (async () => {
+ const { Client } = await import('@synonymdev/pubky');
+ clientInstance = new Client();
+ logger.info('PubkyClientFactory', 'Pubky Client singleton initialized');
+ return clientInstance;
+ })();
+
+ return initializationPromise;
+}
+
+/**
+ * Get or create the singleton Pubky Client instance (async version)
+ * Use this when you need to ensure the SDK is fully loaded
+ * @returns Promise that resolves to the shared Pubky Client instance
+ */
+export async function getPubkyClientAsync(): Promise {
+ return initializePubkyClient();
+}
+
+/**
+ * Reset the singleton instance (useful for testing)
+ */
+export function resetPubkyClient(): void {
+ clientInstance = null;
+ initializationPromise = null;
+}
+