-
- Poll Details
-
-
-
- Poll ID:
- {decodedData.pollId?.slice(0, 8)}...
-
-
- Voting Mode:
- {decodedData.voteData?.optionId
- ? "Single Choice"
- : "Ranked Choice"}
-
- {#if decodedData.voteData?.optionId}
+ {#if decodedData.pollId && decodedData.voteData}
+
+
+
+ Poll Details
+
+
- Selected Option:
- Option {decodedData.voteData.optionId + 1}
+ Poll ID:
+ {decodedData.pollId?.slice(0, 8)}...
- {:else if decodedData.voteData?.ranks}
-
Rankings:
-
- {#each Object.entries(decodedData.voteData.ranks) as [rank, optionIndex]}
-
- {rank === "1"
- ? "1st"
- : rank === "2"
- ? "2nd"
- : "3rd"}: Option {(optionIndex as number) +
- 1}
-
- {/each}
+
Voting Mode:
+ {decodedData.voteData?.optionId
+ ? "Single Choice"
+ : "Ranked Choice"}
+
+ {#if decodedData.voteData?.optionId}
+
+ Selected Option:
+ Option {decodedData.voteData.optionId + 1}
+
+ {:else if decodedData.voteData?.ranks}
+
+
Rankings:
+
+ {#each Object.entries(decodedData.voteData.ranks) as [rank, optionIndex]}
+
+ {rank === "1"
+ ? "1st"
+ : rank === "2"
+ ? "2nd"
+ : "3rd"}: Option {(optionIndex as number) +
+ 1}
+
+ {/each}
+
+ {/if}
+
+
+ {:else}
+
+
+
+ Message Details
+
+
+
+
Message:
+
+ {decodedData.message}
+
- {/if}
+
+
Session ID:
+
+ {decodedData.sessionId?.slice(0, 8)}...
+
+
+
-
+ {/if}
@@ -218,8 +267,9 @@ function handleRetry() {
Security Notice
- By signing this message, you're confirming your
- vote. This action cannot be undone.
+ {decodedData.pollId
+ ? "By signing this message, you're confirming your vote. This action cannot be undone."
+ : "By signing this message, you're confirming your agreement to the content. This action cannot be undone."}
@@ -239,7 +289,7 @@ function handleRetry() {
callback={handleSign}
class="flex-1"
>
- Sign Vote
+ {decodedData.pollId ? "Sign Vote" : "Sign Message"}
@@ -254,7 +304,9 @@ function handleRetry() {
- Signing Your Vote
+ {decodedData?.pollId
+ ? "Signing Your Vote"
+ : "Signing Your Message"}
Please wait while we process your signature...
@@ -283,11 +335,14 @@ function handleRetry() {
- Vote Signed Successfully!
+ {decodedData?.pollId
+ ? "Vote Signed Successfully!"
+ : "Message Signed Successfully!"}
- Your vote has been signed and submitted to the voting
- system.
+ {decodedData?.pollId
+ ? "Your vote has been signed and submitted to the voting system."
+ : "Your message has been signed and submitted successfully."}
diff --git a/infrastructure/web3-adapter/MAPPING_RULES.md b/infrastructure/web3-adapter/MAPPING_RULES.md
new file mode 100644
index 00000000..64dd3913
--- /dev/null
+++ b/infrastructure/web3-adapter/MAPPING_RULES.md
@@ -0,0 +1,178 @@
+# Web3-Adapter Mapping Rules
+
+This document explains how to create mappings for the web3-adapter system, which enables data exchange between different platforms using a universal ontology.
+
+## Basic Structure
+
+A mapping file defines how local database fields map to global ontology fields. The structure is:
+
+```json
+{
+ "tableName": "local_table_name",
+ "schemaId": "global_schema_uuid",
+ "ownerEnamePath": "path_to_owner_ename",
+ "ownedJunctionTables": ["junction_table1", "junction_table2"],
+ "localToUniversalMap": {
+ "localField": "globalField",
+ "localRelation": "tableName(relationPath),globalAlias"
+ }
+}
+```
+
+## Field Mapping
+
+### Direct Field Mapping
+
+```json
+"localField": "globalField"
+```
+
+Maps a local field directly to a global field with the same name.
+
+### Relation Mapping
+
+```json
+"localRelation": "tableName(relationPath),globalAlias"
+```
+
+Maps a local relation to a global field, where:
+
+- `tableName` is the referenced table name
+- `relationPath` is the path to the relation data
+- `globalAlias` is the target global field name
+
+### Array Relation Mapping
+
+```json
+"participants": "users(participants[].id),participantIds"
+```
+
+Maps an array of relations:
+
+- `participants[].id` extracts the `id` field from each item in the `participants` array
+- `users()` resolves each ID to a global user reference
+- `participantIds` is the target global field name
+
+## Special Functions
+
+### Date Conversion (`__date`)
+
+Converts various timestamp formats to ISO string format.
+
+```json
+"createdAt": "__date(createdAt)"
+"timestamp": "__date(calc(timestamp * 1000))"
+```
+
+**Supported input formats:**
+
+- Unix timestamp (number)
+- Firebase v8 timestamp (`{_seconds: number}`)
+- Firebase v9+ timestamp (`{seconds: number}`)
+- Firebase Timestamp objects
+- Date objects
+- UTC strings
+
+### Calculation (`__calc`)
+
+Performs mathematical calculations using field values.
+
+```json
+"total": "__calc(quantity * price)"
+"average": "__calc((score1 + score2 + score3) / 3)"
+```
+
+**Features:**
+
+- Supports basic arithmetic operations (+, -, \*, /, etc.)
+- Can reference other fields in the same entity
+- Automatically resolves field values before calculation
+
+## Owner Path
+
+The `ownerEnamePath` defines how to determine which eVault owns the data:
+
+```json
+"ownerEnamePath": "ename" // Direct field
+"ownerEnamePath": "users(createdBy.ename)" // Nested via relation
+"ownerEnamePath": "users(participants[].ename)" // Array relation
+```
+
+## Junction Tables
+
+Junction tables (many-to-many relationships) can be marked as owned:
+
+```json
+"ownedJunctionTables": [
+ "user_followers",
+ "user_following"
+]
+```
+
+When junction table data changes, it triggers updates to the parent entity.
+
+## Examples
+
+### User Mapping
+
+```json
+{
+ "tableName": "users",
+ "schemaId": "550e8400-e29b-41d4-a716-446655440000",
+ "ownerEnamePath": "ename",
+ "ownedJunctionTables": ["user_followers", "user_following"],
+ "localToUniversalMap": {
+ "handle": "username",
+ "name": "displayName",
+ "description": "bio",
+ "avatarUrl": "avatarUrl",
+ "ename": "ename",
+ "followers": "followers",
+ "following": "following"
+ }
+}
+```
+
+### Group with Relations
+
+```json
+{
+ "tableName": "groups",
+ "schemaId": "550e8400-e29b-41d4-a716-446655440003",
+ "ownerEnamePath": "users(participants[].ename)",
+ "localToUniversalMap": {
+ "name": "name",
+ "description": "description",
+ "owner": "owner",
+ "admins": "users(admins),admins",
+ "participants": "users(participants[].id),participantIds",
+ "createdAt": "__date(createdAt)",
+ "updatedAt": "__date(updatedAt)"
+ }
+}
+```
+
+## Best Practices
+
+1. **Use descriptive global field names** that match the ontology schema
+2. **Handle timestamps consistently** using `__date()` function
+3. **Map relations properly** using the `tableName(relationPath)` syntax
+4. **Use aliases** when the global field name differs from the local field
+5. **Test mappings** with sample data to ensure proper conversion
+6. **Document complex mappings** with comments explaining the logic
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Missing relations**: Ensure the referenced table has a mapping
+2. **Invalid paths**: Check that the relation path matches your entity structure
+3. **Type mismatches**: Use `__date()` for timestamps, `__calc()` for calculations
+4. **Circular references**: Avoid mapping entities that reference each other infinitely
+
+### Debug Tips
+
+- Check the console for mapping errors
+- Verify that all referenced tables have mappings
+- Test with simple data first, then add complexity
+- Use the `__calc()` function to debug field values
diff --git a/platforms/blabsy/.env.production b/platforms/blabsy/.env.production
deleted file mode 100644
index ca10eb4d..00000000
--- a/platforms/blabsy/.env.production
+++ /dev/null
@@ -1,2 +0,0 @@
-# Preview URL
-NEXT_PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL
diff --git a/platforms/blabsy/.firebaserc b/platforms/blabsy/.firebaserc
index d4339af8..54912f1d 100644
--- a/platforms/blabsy/.firebaserc
+++ b/platforms/blabsy/.firebaserc
@@ -1,5 +1,5 @@
{
"projects": {
- "default": "twitter-clone-ccrsxx"
+ "default": "w3ds-staging"
}
}
diff --git a/platforms/blabsy/firestore.rules b/platforms/blabsy/firestore.rules
index 8dfe4997..23ca4eef 100644
--- a/platforms/blabsy/firestore.rules
+++ b/platforms/blabsy/firestore.rules
@@ -16,12 +16,6 @@ service cloud.firestore {
function isValidImages(images) {
return (images is list && images.size() <= 4) || images == null;
}
-
- function isChatParticipant(chatId) {
- return request.auth != null &&
- exists(/databases/$(database)/documents/chats/$(chatId)) &&
- request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
- }
match /tweets/{tweetId} {
allow read, update: if request.auth != null;
@@ -31,30 +25,43 @@ service cloud.firestore {
allow delete: if isAuthorized(resource.data.createdBy);
}
- match /users/{document=**} {
+ // Specific rule for user stats (fixes like permission issue)
+ match /users/{userId}/stats/{docId} {
+ allow read, create, update: if request.auth != null &&
+ (request.auth.uid == userId || request.auth.uid == userId.replace('@', ''));
+ }
+
+ // Specific rule for user bookmarks
+ match /users/{userId}/bookmarks/{docId} {
+ allow read, write, create: if request.auth != null && request.auth.uid == userId;
+ }
+
+ // General users rule (for top-level user documents)
+ match /users/{userId} {
allow read: if request.auth != null;
allow create: if isAdmin();
- allow update: if request.auth != null && (request.auth.uid == resource.data.id || isAdmin());
+ allow update: if request.auth != null && (request.auth.uid == userId || isAdmin());
allow delete: if isAdmin();
}
match /chats/{chatId} {
allow read: if request.auth != null;
- allow create: if request.auth != null && request.auth.uid in request.resource.data.participants;
- allow update: if request.auth != null && request.auth.uid in resource.data.participants;
- allow delete: if request.auth != null && request.auth.uid in resource.data.participants;
+ allow create: if request.auth != null &&
+ (request.auth.uid in request.resource.data.participants ||
+ ('@' + request.auth.uid) in request.resource.data.participants);
+ allow update: if request.auth != null &&
+ (request.auth.uid in resource.data.participants ||
+ ('@' + request.auth.uid) in resource.data.participants);
+ allow delete: if request.auth != null &&
+ (request.auth.uid in resource.data.participants ||
+ ('@' + request.auth.uid) in resource.data.participants);
}
match /chats/{chatId}/messages/{messageId} {
allow read: if request.auth != null;
- allow create: if request.auth != null &&
- request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants &&
- request.auth.uid == request.resource.data.senderId;
- allow update: if request.auth != null &&
- request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants;
- allow delete: if request.auth != null &&
- request.auth.uid in get(/databases/$(database)/documents/chats/$(chatId)).data.participants &&
- request.auth.uid == resource.data.senderId;
+ allow create: if request.auth != null;
+ allow update: if request.auth != null;
+ allow delete: if request.auth != null;
}
}
-}
+}
\ No newline at end of file
diff --git a/platforms/blabsy/next.config.js b/platforms/blabsy/next.config.js
index 6d9d3f05..66a87635 100644
--- a/platforms/blabsy/next.config.js
+++ b/platforms/blabsy/next.config.js
@@ -1,10 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
- reactStrictMode: true,
- swcMinify: true,
- images: {
- unoptimized: true
- }
+ reactStrictMode: true,
+ swcMinify: true,
+ images: {
+ unoptimized: true
+ },
+ eslint: {
+ ignoreDuringBuilds: true,
+ }
};
module.exports = nextConfig;
diff --git a/platforms/blabsy/src/components/chat/add-members.tsx b/platforms/blabsy/src/components/chat/add-members.tsx
index 7d952cd5..11216b0b 100644
--- a/platforms/blabsy/src/components/chat/add-members.tsx
+++ b/platforms/blabsy/src/components/chat/add-members.tsx
@@ -183,7 +183,7 @@ export function AddMembers({
!selectedUsers.some(
(selected) => selected.id === userData.id
) && // Exclude already selected
- !currentChat?.participants.includes(userData.id) && // Exclude existing chat participants
+ (newChat || !currentChat?.participants.includes(userData.id)) && // Only exclude existing participants if NOT creating new chat
(userData.name
?.toLowerCase()
.includes(query.toLowerCase()) ||
@@ -478,7 +478,7 @@ export function AddMembers({
(u) => u.id === userItem.id
);
const isExistingMember =
- currentChat?.participants.includes(userItem.id);
+ !newChat && currentChat?.participants.includes(userItem.id);
return (
0) {
+ console.log('ChatList: Fetched new participant data:', newParticipantData);
setParticipantData((prev) => ({
...prev,
...newParticipantData
@@ -55,7 +56,7 @@ export function ChatList(): JSX.Element {
};
void fetchParticipantData();
- }, [chats, user, participantData]);
+ }, [chats, user]); // Removed participantData from dependencies
if (loading) {
console.log('ChatList: Loading state');
@@ -110,7 +111,19 @@ export function ChatList(): JSX.Element {
}`}
>
- {participant?.photoURL ? (
+ {chat.type === 'group' ? (
+ chat.photoURL ? (
+
+ ) : (
+
+ )
+ ) : participant?.photoURL ? (
- {otherUser?.photoURL ? (
+ {currentChat.type === 'group' ? (
+ currentChat.photoURL ? (
+
+ ) : (
+
+ )
+ ) : otherUser?.photoURL ? (
- {getChatType(currentChat) === 'direct'
- ? 'Direct Message'
+ {currentChat.type === 'direct'
+ ? otherUser?.username
+ ? `@${otherUser.username}`
+ : 'Direct Message'
: `${currentChat.participants.length} participants`}
diff --git a/platforms/blabsy/src/components/chat/group-settings.tsx b/platforms/blabsy/src/components/chat/group-settings.tsx
index 7a238396..d8582dae 100644
--- a/platforms/blabsy/src/components/chat/group-settings.tsx
+++ b/platforms/blabsy/src/components/chat/group-settings.tsx
@@ -1,12 +1,14 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useRef, ChangeEvent } from 'react';
import { useChat } from '@lib/context/chat-context';
import { useAuth } from '@lib/context/auth-context';
import Image from 'next/image';
-import { doc, getDoc } from 'firebase/firestore';
+import { doc, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
import { db } from '@lib/firebase/app';
import type { User } from '@lib/types/user';
import { Dialog } from '@headlessui/react';
import { UserIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
+import { storage } from '@lib/firebase/app';
export function GroupSettings({
open,
@@ -15,15 +17,34 @@ export function GroupSettings({
open: boolean;
onClose: () => void;
}): JSX.Element {
- const { currentChat } = useChat();
+ const { currentChat, setCurrentChat } = useChat();
const { user } = useAuth();
const [otherUser, setOtherUser] = useState
(null);
const [isLoading, setIsLoading] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Form state
+ const [groupName, setGroupName] = useState('');
+ const [groupDescription, setGroupDescription] = useState('');
+ const [groupPhotoURL, setGroupPhotoURL] = useState(null);
+ const [newPhotoFile, setNewPhotoFile] = useState(null);
+
+ const fileInputRef = useRef(null);
const otherParticipant = currentChat?.participants.find(
(p) => p !== user?.id
);
+ // Initialize form state when chat changes
+ useEffect(() => {
+ if (currentChat) {
+ setGroupName(currentChat.name || '');
+ setGroupDescription(currentChat.description || '');
+ setGroupPhotoURL(currentChat.photoURL || null);
+ setNewPhotoFile(null);
+ }
+ }, [currentChat]);
+
useEffect(() => {
if (!otherParticipant) {
return;
@@ -55,107 +76,204 @@ export function GroupSettings({
}
}, [currentChat]);
+ const handlePhotoChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ setNewPhotoFile(file);
+ // Create preview URL
+ const previewURL = URL.createObjectURL(file);
+ setGroupPhotoURL(previewURL);
+ }
+ };
+
+ const uploadPhoto = async (file: File): Promise => {
+ const photoRef = ref(storage, `group-photos/${currentChat?.id}/${Date.now()}-${file.name}`);
+ await uploadBytes(photoRef, file);
+ return await getDownloadURL(photoRef);
+ };
+
+ const handleSave = async () => {
+ if (!currentChat || !user) return;
+
+ setIsSaving(true);
+ try {
+ let finalPhotoURL = groupPhotoURL;
+
+ // Upload new photo if one was selected
+ if (newPhotoFile) {
+ finalPhotoURL = await uploadPhoto(newPhotoFile);
+ }
+
+ // Update the chat document
+ const chatRef = doc(db, 'chats', currentChat.id);
+ await updateDoc(chatRef, {
+ name: groupName.trim() || null,
+ description: groupDescription.trim() || null,
+ photoURL: finalPhotoURL,
+ updatedAt: serverTimestamp()
+ });
+
+ // Update the local chat context to reflect changes immediately
+ const updatedChat = {
+ ...currentChat,
+ name: groupName.trim() || undefined,
+ description: groupDescription.trim() || undefined,
+ photoURL: finalPhotoURL || undefined
+ };
+ setCurrentChat(updatedChat);
+
+ // Close the modal
+ onClose();
+ } catch (error) {
+ console.error('Error updating group settings:', error);
+ // You might want to show an error message to the user here
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const isAdmin = user && (currentChat?.admins?.includes(user.id) || currentChat?.owner === user.id);
+ const isGroup = currentChat?.type === 'group';
+
return (
- {(user && currentChat?.admins?.includes(user?.id)) ||
- currentChat?.owner === user?.id
- ? 'Edit Group Settings'
- : 'Group Info'}
+ {isGroup
+ ? (isAdmin ? 'Edit Group Settings' : 'Group Info')
+ : 'Chat Info'
+ }
-
- {((user && currentChat?.admins?.includes(user?.id)) ||
- currentChat?.owner === user?.id) && (
+
+ {isGroup && (
+ <>
+
+
+ Group Name
+ {isAdmin ? (
+ setGroupName(e.target.value)}
+ placeholder='Enter group name'
+ className='mt-1 block w-full py-3 px-4 rounded-md border-gray-300 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
+ />
+ ) : (
+
+ {currentChat?.name || 'No name set'}
+
+ )}
+
+
+
+
+ Description
+ {isAdmin ? (
+ setGroupDescription(e.target.value)}
+ placeholder='Enter group description'
+ rows={3}
+ className='mt-1 py-3 px-4 resize-none block w-full rounded-md border-gray-300 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
+ />
+ ) : (
+
+ {currentChat?.description || "It's empty out here."}
+
+ )}
+
+
+ >
+ )}
+
+ {isGroup && isAdmin && (
Cancel
- Save
+ {isSaving ? 'Saving...' : 'Save'}
)}
diff --git a/platforms/blabsy/src/components/input/image-preview.tsx b/platforms/blabsy/src/components/input/image-preview.tsx
index a6f55fe3..b22702c6 100644
--- a/platforms/blabsy/src/components/input/image-preview.tsx
+++ b/platforms/blabsy/src/components/input/image-preview.tsx
@@ -1,8 +1,9 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import cn from 'clsx';
import { useModal } from '@lib/hooks/useModal';
import { preventBubbling } from '@lib/utils';
+import { combineBase64Images } from '@lib/utils/image-utils';
import { ImageModal } from '@components/modal/image-modal';
import { Modal } from '@components/modal/modal';
import { NextImage } from '@components/ui/next-image';
@@ -43,7 +44,7 @@ const postImageBorderRadius: Readonly = {
export function ImagePreview({
tweet,
viewTweet,
- previewCount,
+ previewCount: _previewCount, // Renamed to avoid unused variable warning
imagesPreview,
removeImage
}: ImagePreviewProps): JSX.Element {
@@ -54,11 +55,19 @@ export function ImagePreview({
const { open, openModal, closeModal } = useModal();
+ // Combine accidentally separated base64 images
+ const processedImages = useMemo(() => {
+ return combineBase64Images(imagesPreview);
+ }, [imagesPreview]);
+
+ // Update previewCount based on processed images
+ const actualPreviewCount = processedImages.length;
+
useEffect(() => {
- const imageData = imagesPreview[selectedIndex];
+ const imageData = processedImages[selectedIndex];
setSelectedImage(imageData);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedIndex]);
+ }, [selectedIndex, processedImages]);
const handleVideoStop = (): void => {
if (videoRef.current) videoRef.current.pause();
@@ -75,9 +84,9 @@ export function ImagePreview({
const nextIndex =
type === 'prev'
? selectedIndex === 0
- ? previewCount - 1
+ ? actualPreviewCount - 1
: selectedIndex - 1
- : selectedIndex === previewCount - 1
+ : selectedIndex === actualPreviewCount - 1
? 0
: selectedIndex + 1;
@@ -108,15 +117,15 @@ export function ImagePreview({
- {imagesPreview.map(({ id, src, alt }, index) => {
+ {processedImages.map(({ id, src, alt }, index) => {
const isVideo =
- imagesPreview[index].type?.includes('video');
+ processedImages[index].type?.includes('video');
return (
({
- bio,
- name,
- website,
- photoURL,
- location,
- coverPhotoURL
+ bio: null,
+ name: '',
+ website: null,
+ photoURL: '',
+ location: null,
+ coverPhotoURL: null
});
+ // Update editUserData when user changes
+ useEffect(() => {
+ if (user) {
+ setEditUserData({
+ bio: user.bio,
+ name: user.name,
+ website: user.website,
+ photoURL: user.photoURL,
+ location: user.location,
+ coverPhotoURL: user.coverPhotoURL
+ });
+ }
+ }, [user]);
+
const [userImages, setUserImages] = useState({
photoURL: [],
coverPhotoURL: []
@@ -58,6 +69,11 @@ export function UserEditProfile({ hide }: UserEditProfileProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => cleanImage, []);
+ // Early return if user is not loaded yet
+ if (!user) {
+ return
; // Return empty div while loading
+ }
+
const inputNameError = !editUserData.name?.trim()
? "Name can't be blank"
: '';
@@ -75,8 +91,8 @@ export function UserEditProfile({ hide }: UserEditProfileProps): JSX.Element {
const newImages: Partial> = {
coverPhotoURL:
- coverPhotoURL === editUserData.coverPhotoURL
- ? coverPhotoURL
+ user.coverPhotoURL === editUserData.coverPhotoURL
+ ? user.coverPhotoURL
: newCoverPhotoURL?.[0].src ?? null,
...(newPhotoURL && { photoURL: newPhotoURL[0].src })
};
@@ -174,12 +190,12 @@ export function UserEditProfile({ hide }: UserEditProfileProps): JSX.Element {
const resetUserEditData = (): void =>
setEditUserData({
- bio,
- name,
- website,
- photoURL,
- location,
- coverPhotoURL
+ bio: user.bio,
+ name: user.name,
+ website: user.website,
+ photoURL: user.photoURL,
+ location: user.location,
+ coverPhotoURL: user.coverPhotoURL
});
const handleChange =
@@ -237,7 +253,7 @@ export function UserEditProfile({ hide }: UserEditProfileProps): JSX.Element {
closeModal={closeModal}
>
; // Return empty div while loading
+ }
+
+ const { name, username } = user;
return (
<>
diff --git a/platforms/blabsy/src/lib/context/auth-context.tsx b/platforms/blabsy/src/lib/context/auth-context.tsx
index 7bfe8d61..fd0abdb9 100644
--- a/platforms/blabsy/src/lib/context/auth-context.tsx
+++ b/platforms/blabsy/src/lib/context/auth-context.tsx
@@ -91,15 +91,26 @@ export function AuthContextProvider({
const { id } = user;
- const unsubscribeUser = onSnapshot(doc(usersCollection, id), (doc) => {
- setUser(doc.data() as User);
- });
+ const unsubscribeUser = onSnapshot(
+ doc(usersCollection, id),
+ (doc) => {
+ setUser(doc.data() as User);
+ },
+ (error) => {
+ console.error('[DEBUG] Error in user document listener:', error);
+ // Don't throw here, just log the error
+ }
+ );
const unsubscribeBookmarks = onSnapshot(
userBookmarksCollection(id),
(snapshot) => {
const bookmarks = snapshot.docs.map((doc) => doc.data());
setUserBookmarks(bookmarks);
+ },
+ (error) => {
+ console.error('[DEBUG] Error in bookmarks listener:', error);
+ // Don't throw here, just log the error
}
);
diff --git a/platforms/blabsy/src/lib/context/user-context.tsx b/platforms/blabsy/src/lib/context/user-context.tsx
index c55c7247..2e6575fe 100644
--- a/platforms/blabsy/src/lib/context/user-context.tsx
+++ b/platforms/blabsy/src/lib/context/user-context.tsx
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import type { User } from '@lib/types/user';
type UserContext = {
- user: User;
+ user: User | null;
loading: boolean;
};
@@ -29,9 +29,5 @@ export function useUser(): UserContext {
if (!context)
throw new Error('useUser must be used within an UserContextProvider');
- // Since loading is handled at the root level, user should never be null here
- return {
- user: context.user!,
- loading: context.loading
- };
+ return context;
}
diff --git a/platforms/blabsy/src/lib/firebase/utils.ts b/platforms/blabsy/src/lib/firebase/utils.ts
index 63e59098..204d5727 100644
--- a/platforms/blabsy/src/lib/firebase/utils.ts
+++ b/platforms/blabsy/src/lib/firebase/utils.ts
@@ -262,43 +262,90 @@ export function manageLike(
tweetId: string
) {
return async (): Promise => {
- const batch = writeBatch(db);
-
- const userStatsRef = doc(userStatsCollection(userId), 'stats');
+ console.log(`[DEBUG] Starting ${type} operation for user ${userId} on tweet ${tweetId}`);
+
+ const userStatsRef = doc(userStatsCollection(userId));
const tweetRef = doc(tweetsCollection, tweetId);
- // Ensure stats document exists before updating
- await ensureUserStatsExists(userId);
-
- if (type === 'like') {
- batch.update(tweetRef, {
- userLikes: arrayUnion(userId),
- updatedAt: serverTimestamp()
- });
- batch.set(
- userStatsRef,
- {
- likes: arrayUnion(tweetId),
- updatedAt: serverTimestamp()
- },
- { merge: true }
- );
- } else {
- batch.update(tweetRef, {
- userLikes: arrayRemove(userId),
- updatedAt: serverTimestamp()
- });
- batch.set(
- userStatsRef,
- {
- likes: arrayRemove(tweetId),
+ console.log(`[DEBUG] User stats ref: ${userStatsRef.path}`);
+ console.log(`[DEBUG] Tweet ref: ${tweetRef.path}`);
+
+ try {
+ // Check if user stats document exists, create if it doesn't
+ const userStatsDoc = await getDoc(userStatsRef);
+ if (!userStatsDoc.exists()) {
+ console.log(`[DEBUG] User stats document doesn't exist, creating it...`);
+ await setDoc(userStatsRef, {
+ likes: [],
+ tweets: [],
updatedAt: serverTimestamp()
- },
- { merge: true }
- );
+ });
+ console.log(`[DEBUG] User stats document created successfully`);
+ }
+
+ if (type === 'like') {
+ console.log(`[DEBUG] Adding like to tweet...`);
+ try {
+ await updateDoc(tweetRef, {
+ userLikes: arrayUnion(userId),
+ updatedAt: serverTimestamp()
+ });
+ console.log(`[DEBUG] Tweet updated successfully`);
+ } catch (tweetError) {
+ console.error(`[DEBUG] Error updating tweet:`, tweetError);
+ throw tweetError;
+ }
+
+ console.log(`[DEBUG] Adding tweet to user stats...`);
+ try {
+ await setDoc(
+ userStatsRef,
+ {
+ likes: arrayUnion(tweetId),
+ updatedAt: serverTimestamp()
+ },
+ { merge: true }
+ );
+ console.log(`[DEBUG] User stats updated successfully`);
+ } catch (statsError) {
+ console.error(`[DEBUG] Error updating user stats:`, statsError);
+ throw statsError;
+ }
+ } else {
+ console.log(`[DEBUG] Removing like from tweet...`);
+ try {
+ await updateDoc(tweetRef, {
+ userLikes: arrayRemove(userId),
+ updatedAt: serverTimestamp()
+ });
+ console.log(`[DEBUG] Tweet updated successfully`);
+ } catch (tweetError) {
+ console.error(`[DEBUG] Error updating tweet:`, tweetError);
+ throw tweetError;
+ }
+
+ console.log(`[DEBUG] Removing tweet from user stats...`);
+ try {
+ await setDoc(
+ userStatsRef,
+ {
+ likes: arrayRemove(tweetId),
+ updatedAt: serverTimestamp()
+ },
+ { merge: true }
+ );
+ console.log(`[DEBUG] User stats updated successfully`);
+ } catch (statsError) {
+ console.error(`[DEBUG] Error updating user stats:`, statsError);
+ throw statsError;
+ }
+ }
+
+ console.log(`[DEBUG] Like operation completed successfully`);
+ } catch (error) {
+ console.error(`[DEBUG] Error in like operation:`, error);
+ throw error;
}
-
- await batch.commit();
};
}
@@ -360,35 +407,39 @@ export async function sendMessage(
senderId: string,
text: string
): Promise {
- const batch = writeBatch(db);
+ try {
+ const batch = writeBatch(db);
- const messageId = doc(chatsCollection).id; // Generate a new ID
- const messageRef = doc(chatMessagesCollection(chatId), messageId);
+ const messageId = doc(chatsCollection).id; // Generate a new ID
+ const messageRef = doc(chatMessagesCollection(chatId), messageId);
- console.log('error4', chatsCollection, chatId);
- const chatRef = doc(chatsCollection, chatId);
+ const chatRef = doc(chatsCollection, chatId);
- const messageData: WithFieldValue = {
- id: messageId,
- chatId,
- senderId,
- text,
- createdAt: serverTimestamp(),
- updatedAt: serverTimestamp(),
- readBy: [senderId]
- };
-
- batch.set(messageRef, messageData);
- batch.update(chatRef, {
- lastMessage: {
- text,
+ const messageData: WithFieldValue = {
+ id: messageId,
+ chatId,
senderId,
- timestamp: serverTimestamp()
- },
- updatedAt: serverTimestamp()
- });
+ text,
+ createdAt: serverTimestamp(),
+ updatedAt: serverTimestamp(),
+ readBy: [senderId]
+ };
- await batch.commit();
+ batch.set(messageRef, messageData);
+ batch.update(chatRef, {
+ lastMessage: {
+ text,
+ senderId,
+ timestamp: serverTimestamp()
+ },
+ updatedAt: serverTimestamp()
+ });
+
+ await batch.commit();
+ } catch (error) {
+ console.error('Error sending message:', error);
+ throw error;
+ }
}
export async function markMessageAsRead(
diff --git a/platforms/blabsy/src/lib/types/chat.ts b/platforms/blabsy/src/lib/types/chat.ts
index d1d8606c..5b556d63 100644
--- a/platforms/blabsy/src/lib/types/chat.ts
+++ b/platforms/blabsy/src/lib/types/chat.ts
@@ -12,6 +12,7 @@ export type Chat = {
type?: ChatType; // Make type optional for backward compatibility
name?: string; // Required for group chats
description?: string;
+ photoURL?: string; // Group profile picture URL
participants: string[]; // Array of user IDs
owner?: string; // Required User ID of the chat owner in group chats
admins?: string[]; // Required Array of user IDs for group chats
diff --git a/platforms/blabsy/src/lib/utils/image-utils.ts b/platforms/blabsy/src/lib/utils/image-utils.ts
new file mode 100644
index 00000000..b8fbd5fb
--- /dev/null
+++ b/platforms/blabsy/src/lib/utils/image-utils.ts
@@ -0,0 +1,104 @@
+/**
+ * Combines accidentally separated base64 image URLs
+ * This handles cases where base64 data URLs get split at the comma
+ * and need to be recombined to form valid image sources
+ */
+export function combineBase64Images(images: Array<{ src: string; alt: string; type?: string; id: string }>): Array<{ src: string; alt: string; type?: string; id: string }> {
+ if (!images || images.length === 0) {
+ return images;
+ }
+
+ const result: Array<{ src: string; alt: string; type?: string; id: string }> = [];
+
+ console.log('Processing images for base64 combining:', images.length);
+
+ for (let i = 0; i < images.length; i += 2) {
+ const dataPart = images[i];
+ const chunkPart = images[i + 1];
+
+ if (dataPart && chunkPart) {
+ // Check if this looks like a base64 data URL that got split
+ if (dataPart.src.startsWith('data:') && !chunkPart.src.startsWith('data:')) {
+ // Combine the base64 parts
+ const combinedSrc = `${dataPart.src},${chunkPart.src}`;
+ console.log(`Combined base64 image at index ${i}:`, {
+ original: dataPart.src.substring(0, 50) + '...',
+ chunk: chunkPart.src.substring(0, 50) + '...',
+ combined: combinedSrc.substring(0, 50) + '...'
+ });
+
+ result.push({
+ ...dataPart,
+ src: combinedSrc
+ });
+ } else {
+ // Not a base64 split, add both parts separately
+ result.push(dataPart);
+ result.push(chunkPart);
+ }
+ } else {
+ // Handle odd number of images (last item)
+ if (dataPart) {
+ if (dataPart.src.startsWith('data:')) {
+ console.warn(`Incomplete base64 image at index ${i}, skipping`);
+ } else {
+ result.push(dataPart);
+ }
+ }
+ }
+ }
+
+ console.log(`Processed ${images.length} images into ${result.length} valid images`);
+ return result;
+}
+
+/**
+ * Alternative implementation that processes the entire array
+ * and looks for patterns of split base64 URLs
+ */
+export function combineBase64ImagesAlternative(images: Array<{ src: string; alt: string; type?: string; id: string }>): Array<{ src: string; alt: string; type?: string; id: string }> {
+ if (!images || images.length === 0) {
+ return images;
+ }
+
+ const result: Array<{ src: string; alt: string; type?: string; id: string }> = [];
+ let i = 0;
+
+ while (i < images.length) {
+ const current = images[i];
+
+ // Check if current image starts with data: but doesn't contain a comma
+ if (current.src.startsWith('data:') && !current.src.includes(',')) {
+ // Look for the next image that might be the continuation
+ if (i + 1 < images.length) {
+ const next = images[i + 1];
+
+ // If next doesn't start with data:, it's likely the continuation
+ if (!next.src.startsWith('data:')) {
+ const combinedSrc = `${current.src},${next.src}`;
+ console.log(`Combined base64 image:`, {
+ index: i,
+ original: current.src.substring(0, 50) + '...',
+ chunk: next.src.substring(0, 50) + '...',
+ combined: combinedSrc.substring(0, 50) + '...'
+ });
+
+ result.push({
+ ...current,
+ src: combinedSrc
+ });
+
+ // Skip the next image since we combined it
+ i += 2;
+ continue;
+ }
+ }
+ }
+
+ // Add current image as-is
+ result.push(current);
+ i++;
+ }
+
+ return result;
+}
\ No newline at end of file
diff --git a/platforms/blabsy/storage.rules b/platforms/blabsy/storage.rules
index 8ce74199..157d2c29 100644
--- a/platforms/blabsy/storage.rules
+++ b/platforms/blabsy/storage.rules
@@ -19,5 +19,11 @@ service firebase.storage {
allow create: if isAuthorized(userId) && isValidMedia();
allow update, delete: if false;
}
+
+ match /group-photos/{chatId}/{fileName} {
+ allow read: if request.auth != null;
+ allow create: if request.auth != null && isValidMedia();
+ allow update, delete: if false;
+ }
}
}
diff --git a/platforms/cerberus/src/controllers/WebhookController.ts b/platforms/cerberus/src/controllers/WebhookController.ts
index a84ebeba..e7920670 100644
--- a/platforms/cerberus/src/controllers/WebhookController.ts
+++ b/platforms/cerberus/src/controllers/WebhookController.ts
@@ -3,6 +3,7 @@ import { UserService } from "../services/UserService";
import { GroupService } from "../services/GroupService";
import { MessageService } from "../services/MessageService";
import { CerberusTriggerService } from "../services/CerberusTriggerService";
+import { CharterSignatureService } from "../services/CharterSignatureService";
import { Web3Adapter } from "../../../../infrastructure/web3-adapter/src";
import { User } from "../database/entities/User";
import { Group } from "../database/entities/Group";
@@ -14,6 +15,7 @@ export class WebhookController {
groupService: GroupService;
messageService: MessageService;
cerberusTriggerService: CerberusTriggerService;
+ charterSignatureService: CharterSignatureService;
adapter: Web3Adapter;
constructor(adapter: Web3Adapter) {
@@ -21,6 +23,7 @@ export class WebhookController {
this.groupService = new GroupService();
this.messageService = new MessageService();
this.cerberusTriggerService = new CerberusTriggerService();
+ this.charterSignatureService = new CharterSignatureService();
this.adapter = adapter;
}
@@ -298,6 +301,81 @@ export class WebhookController {
});
}
}
+ } else if (mapping.tableName === "charter_signatures") {
+ console.log("Processing charter signature with data:", local.data);
+
+ // Extract group and user from the signature data
+ let group: Group | null = null;
+ let user: User | null = null;
+
+ // Parse groupId from relation string like "groups(cd8e7ce1-ca76-4564-8fb8-1cbb5c3d1917)"
+ if (local.data.groupId && typeof local.data.groupId === "string") {
+ const groupId = local.data.groupId.split("(")[1].split(")")[0];
+ console.log("Extracted groupId:", groupId);
+ group = await this.groupService.getGroupById(groupId);
+ }
+
+ // Parse userId from relation string like "users(userId)" or handle null case
+ if (local.data.userId && typeof local.data.userId === "string") {
+ const userId = local.data.userId.split("(")[1].split(")")[0];
+ console.log("Extracted userId:", userId);
+ user = await this.userService.getUserById(userId);
+ } else if (local.data.userId === null) {
+ console.log("userId is null, skipping user lookup");
+ // For now, we'll create the signature without a user - you might want to handle this differently
+ }
+
+ if (!group) {
+ console.error("Group not found for charter signature");
+ return res.status(500).send();
+ }
+
+ if (!user) {
+ console.error("User not found for charter signature - userId was null or invalid");
+ return res.status(500).send();
+ }
+
+ if (localId) {
+ console.log("Updating existing charter signature with localId:", localId);
+ // For now, we'll just log that we're updating
+ // You might want to add update logic here if needed
+ console.log("Charter signature update not yet implemented");
+ } else {
+ console.log("Creating new charter signature");
+
+ // Create the charter signature using the service
+ const charterSignature = await this.charterSignatureService.createCharterSignature({
+ data: {
+ id: req.body.id,
+ group: group.id,
+ user: user.id,
+ charterHash: local.data.charterHash,
+ signature: local.data.signature,
+ publicKey: local.data.publicKey,
+ message: local.data.message,
+ createdAt: local.data.createdAt,
+ updatedAt: local.data.updatedAt,
+ }
+ });
+
+ console.log("Created charter signature with ID:", charterSignature.id);
+ this.adapter.addToLockedIds(charterSignature.id);
+ await this.adapter.mappingDb.storeMapping({
+ localId: charterSignature.id,
+ globalId: req.body.id,
+ });
+ console.log("Stored mapping for charter signature:", charterSignature.id, "->", req.body.id);
+
+ // Analyze charter activation after new signature
+ try {
+ await this.charterSignatureService.analyzeCharterActivation(
+ group.id,
+ this.messageService
+ );
+ } catch (error) {
+ console.error("Error analyzing charter activation:", error);
+ }
+ }
}
res.status(200).send();
} catch (e) {
diff --git a/platforms/cerberus/src/database/data-source.ts b/platforms/cerberus/src/database/data-source.ts
index 9de5812d..6044ebb5 100644
--- a/platforms/cerberus/src/database/data-source.ts
+++ b/platforms/cerberus/src/database/data-source.ts
@@ -8,6 +8,7 @@ import { PostgresSubscriber } from "../web3adapter/watchers/subscriber";
import path from "path";
import { UserEVaultMapping } from "./entities/UserEVaultMapping";
import { VotingObservation } from "./entities/VotingObservation";
+import { CharterSignature } from "./entities/CharterSignature";
config({ path: path.resolve(__dirname, "../../../../.env") });
@@ -16,7 +17,16 @@ export const AppDataSource = new DataSource({
url: process.env.CERBERUS_DATABASE_URL,
synchronize: true, // Temporarily enabled to create voting_observations table
logging: process.env.NODE_ENV === "development",
- entities: [User, Group, Message, MetaEnvelopeMap, UserEVaultMapping, VotingObservation],
+ entities: [
+ User,
+ Group,
+ Message,
+ MetaEnvelopeMap,
+ UserEVaultMapping,
+ VotingObservation,
+ CharterSignature,
+ ],
migrations: ["src/database/migrations/*.ts"],
subscribers: [PostgresSubscriber],
-});
\ No newline at end of file
+});
+
diff --git a/platforms/cerberus/src/database/entities/CharterSignature.ts b/platforms/cerberus/src/database/entities/CharterSignature.ts
new file mode 100644
index 00000000..be49a77c
--- /dev/null
+++ b/platforms/cerberus/src/database/entities/CharterSignature.ts
@@ -0,0 +1,50 @@
+import {
+ Entity,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ Column,
+ ManyToOne,
+ JoinColumn,
+} from "typeorm";
+import { Group } from "./Group";
+import { User } from "./User";
+
+@Entity()
+export class CharterSignature {
+ @PrimaryGeneratedColumn("uuid")
+ id!: string;
+
+ @Column()
+ groupId!: string;
+
+ @Column()
+ userId!: string;
+
+ @Column({ type: "text" })
+ charterHash!: string; // Hash of the charter content to track versions
+
+ @Column({ type: "text" })
+ signature!: string; // Cryptographic signature
+
+ @Column({ type: "text" })
+ publicKey!: string; // User's public key
+
+ @Column({ type: "text" })
+ message!: string; // Original message that was signed
+
+ @ManyToOne(() => Group)
+ @JoinColumn({ name: "groupId" })
+ group!: Group;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: "userId" })
+ user!: User;
+
+ @CreateDateColumn()
+ createdAt!: Date;
+
+ @UpdateDateColumn()
+ updatedAt!: Date;
+}
+
diff --git a/platforms/cerberus/src/database/entities/Group.ts b/platforms/cerberus/src/database/entities/Group.ts
index e8088d10..db0e105c 100644
--- a/platforms/cerberus/src/database/entities/Group.ts
+++ b/platforms/cerberus/src/database/entities/Group.ts
@@ -9,6 +9,7 @@ import {
OneToMany,
} from "typeorm";
import { Message } from "./Message";
+import { CharterSignature } from "./CharterSignature";
@Entity()
export class Group {
@@ -30,6 +31,9 @@ export class Group {
@Column({ type: "text", nullable: true })
charter!: string; // Markdown content for the group charter
+ @Column({ default: true })
+ isCharterActive!: boolean; // Whether the charter is currently active and monitoring violations
+
@ManyToMany("User")
@JoinTable({
name: "group_participants",
@@ -41,6 +45,9 @@ export class Group {
@OneToMany(() => Message, (message) => message.group)
messages!: Message[];
+ @OneToMany(() => CharterSignature, (signature) => signature.group)
+ charterSignatures!: CharterSignature[];
+
@CreateDateColumn()
createdAt!: Date;
diff --git a/platforms/cerberus/src/database/entities/VotingObservation.ts b/platforms/cerberus/src/database/entities/VotingObservation.ts
index 3ae9a7e6..669129aa 100644
--- a/platforms/cerberus/src/database/entities/VotingObservation.ts
+++ b/platforms/cerberus/src/database/entities/VotingObservation.ts
@@ -1,4 +1,5 @@
-import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from "typeorm";
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from "typeorm";
+import { User } from "./User";
@Entity("voting_observations")
export class VotingObservation {
@@ -8,6 +9,13 @@ export class VotingObservation {
@Column("uuid")
groupId!: string;
+ @Column("uuid")
+ owner!: string;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: "owner" })
+ ownerUser!: User;
+
@Column("timestamp")
lastCheckTime!: Date;
diff --git a/platforms/cerberus/src/database/migrations/1755282900142-migration.ts b/platforms/cerberus/src/database/migrations/1755282900142-migration.ts
deleted file mode 100644
index c9f39035..00000000
--- a/platforms/cerberus/src/database/migrations/1755282900142-migration.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
-
-export class Migration1755282900142 implements MigrationInterface {
- name = 'Migration1755282900142'
-
- public async up(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE "messages" ADD "isSystemMessage" boolean NOT NULL DEFAULT false`);
- }
-
- public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "isSystemMessage"`);
- }
-
-}
diff --git a/platforms/cerberus/src/services/CerberusTriggerService.ts b/platforms/cerberus/src/services/CerberusTriggerService.ts
index 511f9403..b616af46 100644
--- a/platforms/cerberus/src/services/CerberusTriggerService.ts
+++ b/platforms/cerberus/src/services/CerberusTriggerService.ts
@@ -46,6 +46,41 @@ export class CerberusTriggerService {
return messageText.toLowerCase().trim() === "cerberus trigger";
}
+ /**
+ * Check if a group has Cerberus enabled (has charter and watchdog name is "Cerberus")
+ */
+ async isCerberusEnabled(groupId: string): Promise {
+ try {
+ const group = await this.groupService.getGroupById(groupId);
+ if (!group || !group.charter) {
+ return false;
+ }
+
+ // Check if the watchdog name is specifically set to "Cerberus"
+ const charterText = group.charter.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ const watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (watchdogNameMatch) {
+ const watchdogName = watchdogNameMatch[1].trim();
+ return watchdogName === 'cerberus';
+ }
+
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ const sameLineMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ if (sameLineMatch) {
+ const watchdogName = sameLineMatch[1].trim();
+ return watchdogName === 'cerberus';
+ }
+
+ // Fallback: check if "Watchdog Name: Cerberus" appears anywhere
+ return charterText.includes('watchdog name: cerberus');
+ } catch (error) {
+ console.error("Error checking if Cerberus is enabled for group:", error);
+ return false;
+ }
+ }
+
/**
* Get the last message sent by Cerberus in a group
*/
@@ -89,6 +124,13 @@ export class CerberusTriggerService {
*/
async processCharterChange(groupId: string, groupName: string, oldCharter: string | undefined, newCharter: string): Promise {
try {
+ // Check if Cerberus is enabled for this group
+ const cerberusEnabled = await this.isCerberusEnabled(groupId);
+ if (!cerberusEnabled) {
+ console.log(`Cerberus not enabled for group ${groupId} - skipping charter change processing`);
+ return;
+ }
+
let changeType: 'created' | 'updated' | 'removed';
if (!oldCharter && newCharter) {
@@ -111,6 +153,24 @@ export class CerberusTriggerService {
groupId: groupId,
});
+ // If charter was updated, also handle signature invalidation and detailed analysis
+ if (changeType === 'updated' && oldCharter && newCharter) {
+ try {
+ // Import CharterSignatureService dynamically to avoid circular dependencies
+ const { CharterSignatureService } = await import('./CharterSignatureService');
+ const charterSignatureService = new CharterSignatureService();
+
+ await charterSignatureService.handleCharterTextChange(
+ groupId,
+ oldCharter,
+ newCharter,
+ this.messageService
+ );
+ } catch (error) {
+ console.error("Error handling charter signature invalidation:", error);
+ }
+ }
+
} catch (error) {
console.error("Error processing charter change:", error);
}
@@ -377,6 +437,13 @@ Be thorough and justify your reasoning. Provide clear, actionable recommendation
*/
async processCerberusTrigger(triggerMessage: Message): Promise {
try {
+ // Check if Cerberus is enabled for this group
+ const cerberusEnabled = await this.isCerberusEnabled(triggerMessage.group.id);
+ if (!cerberusEnabled) {
+ console.log(`Cerberus not enabled for group ${triggerMessage.group.id} - skipping trigger processing`);
+ return;
+ }
+
// Get messages since last Cerberus message
const messages = await this.getMessagesSinceLastCerberus(
triggerMessage.group.id,
diff --git a/platforms/cerberus/src/services/CharterMonitoringService.ts b/platforms/cerberus/src/services/CharterMonitoringService.ts
index 67057dd6..62d7186d 100644
--- a/platforms/cerberus/src/services/CharterMonitoringService.ts
+++ b/platforms/cerberus/src/services/CharterMonitoringService.ts
@@ -24,6 +24,21 @@ export class CharterMonitoringService {
*/
async processCharterChange(event: CharterChangeEvent): Promise {
try {
+ // Only process if the watchdog name is specifically set to "Cerberus"
+ const charterText = event.newCharter.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ let watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (!watchdogNameMatch) {
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ watchdogNameMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ }
+
+ if (!watchdogNameMatch || watchdogNameMatch[1].trim() !== 'cerberus') {
+ console.log(`🔍 Cerberus not enabled for group: ${event.groupName} - watchdog name is not "Cerberus"`);
+ return;
+ }
+
console.log(`🔍 Cerberus monitoring charter change in group: ${event.groupName}`);
if (event.changeType === 'created') {
@@ -269,13 +284,51 @@ Just add a new charter with an Automated Watchdog Policy mentioning "Cerberus"!
*/
private charterMentionsCerberus(charterContent: string): boolean {
if (!charterContent) return false;
- return charterContent.toLowerCase().includes('cerberus');
+
+ // Check if the watchdog name is specifically set to "Cerberus"
+ const charterText = charterContent.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ let watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (!watchdogNameMatch) {
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ watchdogNameMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ }
+
+ if (watchdogNameMatch) {
+ const watchdogName = watchdogNameMatch[1].trim();
+ return watchdogName === 'cerberus';
+ }
+
+ // Fallback: check if "Watchdog Name: Cerberus" appears anywhere
+ return charterText.includes('watchdog name: cerberus');
}
/**
* Send a fun periodic check-in message to groups with active charters
*/
async sendPeriodicCheckIn(groupId: string, groupName: string): Promise {
+ // Get the group to check if Cerberus is enabled
+ const group = await this.groupService.getGroupById(groupId);
+ if (!group || !group.charter) {
+ console.log(`🔍 No charter found for group: ${groupName} - skipping periodic check-in`);
+ return;
+ }
+
+ const charterText = group.charter.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ let watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (!watchdogNameMatch) {
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ watchdogNameMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ }
+
+ if (!watchdogNameMatch || watchdogNameMatch[1].trim() !== 'cerberus') {
+ console.log(`🔍 Cerberus not enabled for group: ${groupName} - watchdog name is not "Cerberus"`);
+ return;
+ }
+
const messageText = `🐕 **Cerberus Check-In!** 🐕
Just dropping by to say hello! I'm still here, watching over your charter in ${groupName}.
diff --git a/platforms/cerberus/src/services/CharterSignatureService.ts b/platforms/cerberus/src/services/CharterSignatureService.ts
new file mode 100644
index 00000000..14f7bc70
--- /dev/null
+++ b/platforms/cerberus/src/services/CharterSignatureService.ts
@@ -0,0 +1,283 @@
+import { AppDataSource } from "../database/data-source";
+import { CharterSignature } from "../database/entities/CharterSignature";
+import { Group } from "../database/entities/Group";
+import { User } from "../database/entities/User";
+import { OpenAIService, CharterAnalysisResult } from "./OpenAIService";
+import { MessageService } from "./MessageService";
+
+export class CharterSignatureService {
+ private signatureRepository = AppDataSource.getRepository(CharterSignature);
+ private groupRepository = AppDataSource.getRepository(Group);
+ private userRepository = AppDataSource.getRepository(CharterSignature);
+
+ /**
+ * Create a new charter signature from webhook data
+ */
+ async createCharterSignature(webhookData: any): Promise {
+ try {
+ // Extract the data from the webhook payload
+ const signatureData = webhookData.data || webhookData;
+
+ // Create the charter signature entity
+ const charterSignature = this.signatureRepository.create({
+ id: signatureData.id,
+ groupId: signatureData.group,
+ userId: signatureData.user,
+ charterHash: signatureData.charterHash,
+ signature: signatureData.signature,
+ publicKey: signatureData.publicKey,
+ message: signatureData.message,
+ createdAt: signatureData.createdAt ? new Date(signatureData.createdAt) : new Date(),
+ updatedAt: signatureData.updatedAt ? new Date(signatureData.updatedAt) : new Date(),
+ });
+
+ // Save to database
+ const savedSignature = await this.signatureRepository.save(charterSignature);
+ console.log(`✅ Charter signature created in cerberus: ${savedSignature.id}`);
+
+ return savedSignature;
+ } catch (error) {
+ console.error("❌ Error creating charter signature in cerberus:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get all charter signatures for a specific group
+ */
+ async getSignaturesForGroup(groupId: string): Promise {
+ try {
+ return await this.signatureRepository.find({
+ where: { groupId },
+ relations: ['user', 'group'],
+ order: { createdAt: 'DESC' }
+ });
+ } catch (error) {
+ console.error("❌ Error getting signatures for group:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get all charter signatures by a specific user
+ */
+ async getSignaturesByUser(userId: string): Promise {
+ try {
+ return await this.signatureRepository.find({
+ where: { userId },
+ relations: ['group'],
+ order: { createdAt: 'DESC' }
+ });
+ } catch (error) {
+ console.error("❌ Error getting signatures by user:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Check if a user has signed a specific charter (by hash)
+ */
+ async hasUserSignedCharter(groupId: string, userId: string, charterHash: string): Promise {
+ try {
+ const signature = await this.signatureRepository.findOne({
+ where: { groupId, userId, charterHash }
+ });
+ return !!signature;
+ } catch (error) {
+ console.error("❌ Error checking if user signed charter:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Get the latest charter signature for a group
+ */
+ async getLatestSignatureForGroup(groupId: string): Promise {
+ try {
+ return await this.signatureRepository.findOne({
+ where: { groupId },
+ relations: ['user'],
+ order: { createdAt: 'DESC' }
+ });
+ } catch (error) {
+ console.error("❌ Error getting latest signature for group:", error);
+ return null;
+ }
+ }
+
+ /**
+ * Delete all signatures for a group (useful when charter content changes)
+ */
+ async deleteAllSignaturesForGroup(groupId: string): Promise {
+ try {
+ await this.signatureRepository.delete({ groupId });
+ console.log(`🗑️ Deleted all signatures for group: ${groupId}`);
+ } catch (error) {
+ console.error("❌ Error deleting signatures for group:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get signature statistics for a group
+ */
+ async getGroupSignatureStats(groupId: string): Promise<{
+ totalSignatures: number;
+ uniqueUsers: number;
+ lastSignatureDate: Date | null;
+ }> {
+ try {
+ const signatures = await this.signatureRepository.find({
+ where: { groupId },
+ select: ['userId', 'createdAt']
+ });
+
+ const uniqueUsers = new Set(signatures.map(s => s.userId)).size;
+ const lastSignatureDate = signatures.length > 0
+ ? new Date(Math.max(...signatures.map(s => s.createdAt.getTime())))
+ : null;
+
+ return {
+ totalSignatures: signatures.length,
+ uniqueUsers,
+ lastSignatureDate
+ };
+ } catch (error) {
+ console.error("❌ Error getting signature stats for group:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Analyze charter activation after a new signature
+ */
+ async analyzeCharterActivation(
+ groupId: string,
+ messageService: MessageService
+ ): Promise {
+ try {
+ const group = await this.groupRepository.findOne({
+ where: { id: groupId },
+ relations: ['participants', 'charterSignatures']
+ });
+
+ if (!group || !group.charter) {
+ return null;
+ }
+
+ // Check if Cerberus is enabled for this group
+ const charterText = group.charter.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ let watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (!watchdogNameMatch) {
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ watchdogNameMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ }
+
+ if (!watchdogNameMatch || watchdogNameMatch[1].trim() !== 'cerberus') {
+ console.log(`Cerberus not enabled for group ${groupId} - watchdog name is not "Cerberus"`);
+ return null;
+ }
+
+ const currentSignatures = group.charterSignatures.length;
+ const totalParticipants = group.participants.length;
+
+ const openaiService = new OpenAIService();
+ const analysis = await openaiService.analyzeCharterActivation(
+ group.charter,
+ currentSignatures,
+ totalParticipants
+ );
+
+ // Update group's isCharterActive status
+ if (group.isCharterActive !== analysis.isActive) {
+ group.isCharterActive = analysis.isActive;
+ await this.groupRepository.save(group);
+
+ // Post system message about charter status change
+ const charterUrl = `${process.env.PUBLIC_GROUP_CHARTER_BASE_URL}`;
+
+ if (analysis.isActive) {
+ await messageService.createSystemMessage({
+ text: `🚀 Charter is now ACTIVE!\n\n` +
+ `Reason: ${analysis.reason}\n\n` +
+ `Current Status: ${currentSignatures} signatures (${((currentSignatures / totalParticipants) * 100).toFixed(1)}%)\n\n` +
+ `Next Steps: The charter is now monitoring for violations.\n\n` +
+ `View Charter `,
+ groupId
+ });
+ } else {
+ await messageService.createSystemMessage({
+ text: `⏳ Charter Status: INACTIVE\n\n` +
+ `Reason: ${analysis.reason}\n\n` +
+ `Current Status: ${currentSignatures} signatures (${((currentSignatures / totalParticipants) * 100).toFixed(1)}%)\n\n` +
+ `Required: ${analysis.requiredSignatures ? `${analysis.requiredSignatures} signatures` : 'Threshold not met'}\n\n` +
+ `Action: More signatures needed to activate.\n\n` +
+ `View Charter `,
+ groupId
+ });
+ }
+ }
+
+ return analysis;
+ } catch (error) {
+ console.error('Error analyzing charter activation:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Handle charter text changes
+ */
+ async handleCharterTextChange(
+ groupId: string,
+ oldCharter: string | null,
+ newCharter: string,
+ messageService: MessageService
+ ): Promise {
+ try {
+ // Check if Cerberus is enabled for this group
+ const charterText = newCharter.toLowerCase();
+
+ // Look for "Watchdog Name:" followed by "**Cerberus**" on next line (handles markdown)
+ let watchdogNameMatch = charterText.match(/watchdog name:\s*\n\s*\*\*([^*]+)\*\*/);
+ if (!watchdogNameMatch) {
+ // Alternative: look for "Watchdog Name: Cerberus" on same line
+ watchdogNameMatch = charterText.match(/watchdog name:\s*([^\n\r]+)/);
+ }
+
+ if (!watchdogNameMatch || watchdogNameMatch[1].trim() !== 'cerberus') {
+ console.log(`Cerberus not enabled for group ${groupId} - watchdog name is not "Cerberus"`);
+ return;
+ }
+
+ // Set charter as inactive
+ await this.groupRepository.update(groupId, { isCharterActive: false });
+
+ // Delete all existing signatures
+ await this.signatureRepository.delete({ groupId });
+
+ // Analyze changes with OpenAI
+ const openaiService = new OpenAIService();
+ const changeSummary = await openaiService.analyzeCharterChanges(oldCharter, newCharter);
+
+ // Post system message about charter changes
+ const charterUrl = `${process.env.PUBLIC_GROUP_CHARTER_BASE_URL}`;
+
+ await messageService.createSystemMessage({
+ text: `📝 Charter Updated\n\n` +
+ `Summary: ${changeSummary.summary}\n\n` +
+ `Key Changes:\n${changeSummary.keyChanges.map(change => `• ${change}`).join('\n')}\n\n` +
+ `Action Required: ${changeSummary.actionRequired} Users now need to sign the new charter.\n\n` +
+ `Status: Charter is now INACTIVE until re-signed. Threshold requirements must be met again.\n\n` +
+ `View Charter `,
+ groupId
+ });
+
+ console.log(`✅ Charter text change handled for group ${groupId}`);
+ } catch (error) {
+ console.error('Error handling charter text change:', error);
+ }
+ }
+}
\ No newline at end of file
diff --git a/platforms/cerberus/src/services/OpenAIService.ts b/platforms/cerberus/src/services/OpenAIService.ts
new file mode 100644
index 00000000..a261953b
--- /dev/null
+++ b/platforms/cerberus/src/services/OpenAIService.ts
@@ -0,0 +1,242 @@
+import OpenAI from 'openai';
+
+export interface CharterAnalysisResult {
+ isActive: boolean;
+ reason: string;
+ thresholdMet: boolean;
+ currentSignatures: number;
+ requiredSignatures?: number;
+ thresholdType?: 'percentage' | 'absolute' | 'none';
+}
+
+export interface CharterChangeSummary {
+ summary: string;
+ keyChanges: string[];
+ actionRequired: string;
+}
+
+export class OpenAIService {
+ private openai: OpenAI;
+
+ constructor() {
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey) {
+ throw new Error('OPENAI_API_KEY environment variable is required');
+ }
+ this.openai = new OpenAI({ apiKey });
+ }
+
+ /**
+ * Analyze charter signatures to determine if charter should be active
+ */
+ async analyzeCharterActivation(
+ charterText: string,
+ currentSignatures: number,
+ totalParticipants: number
+ ): Promise {
+ try {
+ const prompt = `
+You are an AI assistant that analyzes group charters and determines if they should be considered "active" based on signature requirements.
+
+Charter Text:
+${charterText}
+
+Current Status:
+- Current Signatures: ${currentSignatures}
+- Total Participants: ${totalParticipants}
+- Signature Percentage: ${((currentSignatures / totalParticipants) * 100).toFixed(1)}%
+
+Instructions:
+1. Look for any signature threshold requirements in the charter text (e.g., "minimum 10% signatures", "at least 5 signatures", etc.)
+2. If no threshold is specified, the charter should be active by default
+3. If a threshold is specified, determine if it has been met
+4. Provide a clear reason for your decision
+5. Do NOT use bold formatting with ** symbols in your response
+
+Respond with a JSON object containing:
+- isActive: boolean (whether charter should be active)
+- reason: string (explanation of decision)
+- thresholdMet: boolean (whether threshold was met)
+- currentSignatures: number (current signature count)
+- requiredSignatures: number or undefined (if threshold specified)
+- thresholdType: "percentage" | "absolute" | "none" (type of threshold)
+`;
+
+ const response = await this.openai.chat.completions.create({
+ model: "gpt-4",
+ messages: [{ role: "user", content: prompt }],
+ temperature: 0.1,
+ });
+
+ const content = response.choices[0]?.message?.content;
+ if (!content) {
+ throw new Error('No response from OpenAI');
+ }
+
+ // Try to parse JSON response
+ try {
+ const result = JSON.parse(content) as CharterAnalysisResult;
+ return {
+ ...result,
+ currentSignatures,
+ thresholdMet: result.thresholdMet ?? false,
+ };
+ } catch (parseError) {
+ // Fallback if JSON parsing fails
+ console.warn('Failed to parse OpenAI response as JSON, using fallback logic');
+ return this.fallbackCharterAnalysis(charterText, currentSignatures, totalParticipants);
+ }
+ } catch (error) {
+ console.error('Error analyzing charter activation:', error);
+ return this.fallbackCharterAnalysis(charterText, currentSignatures, totalParticipants);
+ }
+ }
+
+ /**
+ * Analyze charter changes and provide summary
+ */
+ async analyzeCharterChanges(
+ oldCharter: string | null,
+ newCharter: string
+ ): Promise {
+ try {
+ const prompt = `
+You are an AI assistant that analyzes changes to group charters and provides summaries.
+
+Old Charter:
+${oldCharter || 'No previous charter'}
+
+New Charter:
+${newCharter}
+
+Instructions:
+1. Analyze the differences between old and new charter
+2. Provide a concise summary of key changes
+3. List the main modifications
+4. Suggest what actions users might need to do
+5. Do NOT use bold formatting with ** symbols in your response
+6. In actionRequired, always mention that users need to sign the new charter
+7. In summary, always explain what threshold needs to be met for the charter to become active
+
+Respond with a JSON object containing:
+- summary: string (brief overview of changes)
+- keyChanges: string[] (list of main modifications)
+- actionRequired: string (what users need to do)
+`;
+
+ const response = await this.openai.chat.completions.create({
+ model: "gpt-4",
+ messages: [{ role: "user", content: prompt }],
+ temperature: 0.1,
+ });
+
+ const content = response.choices[0]?.message?.content;
+ if (!content) {
+ throw new Error('No response from OpenAI');
+ }
+
+ try {
+ return JSON.parse(content) as CharterChangeSummary;
+ } catch (parseError) {
+ console.warn('Failed to parse OpenAI response as JSON, using fallback');
+ return this.fallbackCharterChangeSummary(oldCharter, newCharter);
+ }
+ } catch (error) {
+ console.error('Error analyzing charter changes:', error);
+ return this.fallbackCharterChangeSummary(oldCharter, newCharter);
+ }
+ }
+
+ /**
+ * Fallback analysis when OpenAI fails
+ */
+ private fallbackCharterAnalysis(
+ charterText: string,
+ currentSignatures: number,
+ totalParticipants: number
+ ): CharterAnalysisResult {
+ // Default behavior: charter is active if no specific threshold mentioned
+ const hasThreshold = charterText.toLowerCase().includes('signature') &&
+ (charterText.toLowerCase().includes('minimum') ||
+ charterText.toLowerCase().includes('at least') ||
+ charterText.toLowerCase().includes('required'));
+
+ if (!hasThreshold) {
+ return {
+ isActive: true,
+ reason: 'No signature threshold specified in charter - active by default',
+ thresholdMet: true,
+ currentSignatures,
+ thresholdType: 'none'
+ };
+ }
+
+ // Simple percentage threshold detection
+ const percentageMatch = charterText.match(/(\d+)%/i);
+ if (percentageMatch) {
+ const requiredPercentage = parseInt(percentageMatch[1]);
+ const currentPercentage = (currentSignatures / totalParticipants) * 100;
+ const thresholdMet = currentPercentage >= requiredPercentage;
+
+ return {
+ isActive: thresholdMet,
+ reason: `Charter requires ${requiredPercentage}% signatures. Current: ${currentPercentage.toFixed(1)}%`,
+ thresholdMet,
+ currentSignatures,
+ requiredSignatures: Math.ceil((requiredPercentage / 100) * totalParticipants),
+ thresholdType: 'percentage'
+ };
+ }
+
+ // Simple absolute threshold detection
+ const absoluteMatch = charterText.match(/(\d+)\s*signatures?/i);
+ if (absoluteMatch) {
+ const requiredSignatures = parseInt(absoluteMatch[1]);
+ const thresholdMet = currentSignatures >= requiredSignatures;
+
+ return {
+ isActive: thresholdMet,
+ reason: `Charter requires ${requiredSignatures} signatures. Current: ${currentSignatures}`,
+ thresholdMet,
+ currentSignatures,
+ requiredSignatures,
+ thresholdType: 'absolute'
+ };
+ }
+
+ // Fallback: assume active if we have any signatures
+ return {
+ isActive: currentSignatures > 0,
+ reason: 'Threshold format not recognized, assuming active with signatures',
+ thresholdMet: currentSignatures > 0,
+ currentSignatures,
+ thresholdType: 'none'
+ };
+ }
+
+ /**
+ * Fallback summary when OpenAI fails
+ */
+ private fallbackCharterChangeSummary(
+ oldCharter: string | null,
+ newCharter: string
+ ): CharterChangeSummary {
+ if (!oldCharter) {
+ return {
+ summary: 'New charter created. Threshold requirements must be met for activation.',
+ keyChanges: ['Charter text added'],
+ actionRequired: 'Review and sign the new charter. Users now need to sign the new charter.'
+ };
+ }
+
+ const oldLength = oldCharter.length;
+ const newLength = newCharter.length;
+ const lengthDiff = newLength - oldLength;
+
+ return {
+ summary: `Charter updated (${lengthDiff > 0 ? '+' : ''}${lengthDiff} characters). Threshold requirements must be met again for activation.`,
+ keyChanges: ['Charter text modified'],
+ actionRequired: 'Review changes and re-sign the charter. Users now need to sign the new charter.'
+ };
+ }
+}
\ No newline at end of file
diff --git a/platforms/cerberus/src/web3adapter/mappings/charter_signature.mapping.json b/platforms/cerberus/src/web3adapter/mappings/charter_signature.mapping.json
new file mode 100644
index 00000000..6388f8af
--- /dev/null
+++ b/platforms/cerberus/src/web3adapter/mappings/charter_signature.mapping.json
@@ -0,0 +1,17 @@
+{
+ "tableName": "charter_signatures",
+ "schemaId": "1d83fada-581d-49b0-b6f5-1fe0766da34f",
+ "ownerEnamePath": "users(userId.ename)",
+ "localToUniversalMap": {
+ "id": "id",
+ "groupId": "groups(groupId),group",
+ "userId": "users(userId),user",
+ "charterHash": "charterHash",
+ "signature": "signature",
+ "publicKey": "publicKey",
+ "message": "message",
+ "createdAt": "__date(createdAt)",
+ "updatedAt": "__date(updatedAt)"
+ },
+ "readOnly": true
+}
diff --git a/platforms/cerberus/src/web3adapter/mappings/group.mapping.json b/platforms/cerberus/src/web3adapter/mappings/group.mapping.json
index 41ea0cae..82ed8f16 100644
--- a/platforms/cerberus/src/web3adapter/mappings/group.mapping.json
+++ b/platforms/cerberus/src/web3adapter/mappings/group.mapping.json
@@ -10,8 +10,9 @@
"admins": "users(admins),admins",
"charter": "charter",
"participants": "users(participants[].id),participantIds",
+ "charterSignatures": "charter_signature(charterSignatures[].id),signatureIds",
"createdAt": "createdAt",
"updatedAt": "updatedAt"
},
"readOnly": true
-}
\ No newline at end of file
+}
diff --git a/platforms/cerberus/src/web3adapter/mappings/voting_observation.mapping.json b/platforms/cerberus/src/web3adapter/mappings/voting_observation.mapping.json
new file mode 100644
index 00000000..b9952b15
--- /dev/null
+++ b/platforms/cerberus/src/web3adapter/mappings/voting_observation.mapping.json
@@ -0,0 +1,19 @@
+{
+ "tableName": "voting_observations",
+ "schemaId": "550e8400-e29b-41d4-a716-446655440005",
+ "ownerEnamePath": "users(owner.ename)",
+ "localToUniversalMap": {
+ "id": "id",
+ "groupId": "groups(groupId),group",
+ "owner": "users(owner),owner",
+ "lastCheckTime": "__date(lastCheckTime)",
+ "lastVoteTime": "__date(lastVoteTime)",
+ "requiredVoteInterval": "requiredVoteInterval",
+ "messagesAnalyzed": "messagesAnalyzed",
+ "timeRangeStart": "__date(timeRangeStart)",
+ "timeRangeEnd": "__date(timeRangeEnd)",
+ "findings": "findings",
+ "createdAt": "__date(createdAt)",
+ "updatedAt": "__date(updatedAt)"
+ }
+}
diff --git a/platforms/cerberus/src/web3adapter/watchers/subscriber.ts b/platforms/cerberus/src/web3adapter/watchers/subscriber.ts
index b495f46a..d0eae40a 100644
--- a/platforms/cerberus/src/web3adapter/watchers/subscriber.ts
+++ b/platforms/cerberus/src/web3adapter/watchers/subscriber.ts
@@ -139,19 +139,29 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
* Called after entity removal.
*/
async afterRemove(event: RemoveEvent) {
- this.handleChange(
- // @ts-ignore
- event.entityId,
- event.metadata.tableName.endsWith("s")
- ? event.metadata.tableName
- : event.metadata.tableName + "s"
- );
+ // For remove events, we only have the entityId, not the full entity
+ // We'll handle this differently to avoid errors
+ const tableName = event.metadata.tableName.endsWith("s")
+ ? event.metadata.tableName
+ : event.metadata.tableName + "s";
+
+ console.log(`Entity removed from ${tableName} with ID: ${event.entityId}`);
+
+ // For now, we'll skip processing removed entities in the web3adapter
+ // since we don't have the full entity data to work with
+ // This prevents the error when trying to access entity.id
}
/**
* Handle entity changes and send to web3adapter
*/
private async handleChange(entity: any, tableName: string): Promise {
+ // Safety check: ensure entity exists and has an id
+ if (!entity || !entity.id) {
+ console.log(`Skipping handleChange for ${tableName}: entity or entity.id is missing`);
+ return;
+ }
+
console.log("=======================================", entity.id)
// Check if this is a junction table
if (tableName === "group_participants") return;
@@ -273,6 +283,8 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
return ["participants", "messages"];
case "Message":
return ["sender", "group"];
+ case "CharterSignature":
+ return ["user", "group"];
default:
return [];
}
diff --git a/platforms/group-charter-manager-api/src/controllers/CharterSigningController.ts b/platforms/group-charter-manager-api/src/controllers/CharterSigningController.ts
new file mode 100644
index 00000000..0f186c03
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/controllers/CharterSigningController.ts
@@ -0,0 +1,177 @@
+import { Request, Response } from "express";
+import { CharterSigningService } from "../services/CharterSigningService";
+
+export class CharterSigningController {
+ private signingService: CharterSigningService | null = null;
+
+ constructor() {
+ try {
+ this.signingService = new CharterSigningService();
+ console.log("CharterSigningController initialized successfully");
+ } catch (error) {
+ console.error("Failed to initialize CharterSigningService:", error);
+ this.signingService = null;
+ }
+ }
+
+ private ensureService(): CharterSigningService {
+ if (!this.signingService) {
+ throw new Error("CharterSigningService not initialized");
+ }
+ return this.signingService;
+ }
+
+ testConnection(): boolean {
+ if (!this.signingService) {
+ return false;
+ }
+ return this.signingService.testConnection();
+ }
+
+ // Create a new signing session for a charter
+ async createSigningSession(req: Request, res: Response) {
+ try {
+ const { groupId, charterData } = req.body;
+ const userId = (req as any).user?.id;
+
+ if (!groupId || !charterData || !userId) {
+ return res.status(400).json({
+ error: "Missing required fields: groupId, charterData, or userId"
+ });
+ }
+
+ const session = await this.ensureService().createSession(groupId, charterData, userId);
+
+ res.json({
+ sessionId: session.sessionId,
+ qrData: session.qrData,
+ expiresAt: session.expiresAt
+ });
+ } catch (error) {
+ console.error("Error creating signing session:", error);
+ res.status(500).json({ error: "Failed to create signing session" });
+ }
+ }
+
+ // Get signing session status via SSE
+ async getSigningSessionStatus(req: Request, res: Response) {
+ try {
+ const { sessionId } = req.params;
+
+ if (!sessionId) {
+ return res.status(400).json({ error: "Session ID is required" });
+ }
+
+ // Set up SSE headers
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Cache-Control'
+ });
+
+ // Send initial status
+ const session = await this.ensureService().getSessionStatus(sessionId);
+ if (session) {
+ res.write(`data: ${JSON.stringify({ type: "status", status: session.status })}\n\n`);
+ }
+
+ // Poll for status changes
+ const interval = setInterval(async () => {
+ const session = await this.ensureService().getSessionStatus(sessionId);
+
+ if (session) {
+ if (session.status === "completed") {
+ res.write(`data: ${JSON.stringify({
+ type: "signed",
+ status: "completed",
+ groupId: session.groupId
+ })}\n\n`);
+ clearInterval(interval);
+ res.end();
+ } else if (session.status === "expired") {
+ res.write(`data: ${JSON.stringify({ type: "expired" })}\n\n`);
+ clearInterval(interval);
+ res.end();
+ }
+ } else {
+ res.write(`data: ${JSON.stringify({ type: "error", message: "Session not found" })}\n\n`);
+ clearInterval(interval);
+ res.end();
+ }
+ }, 1000);
+
+ // Clean up on client disconnect
+ req.on('close', () => {
+ clearInterval(interval);
+ res.end();
+ });
+
+ } catch (error) {
+ console.error("Error getting signing session status:", error);
+ res.status(500).json({ error: "Failed to get signing session status" });
+ }
+ }
+
+ // Handle signed payload callback from eID Wallet
+ async handleSignedPayload(req: Request, res: Response) {
+ try {
+ const { sessionId, signature, publicKey, message } = req.body;
+
+ // Validate required fields
+ const missingFields = [];
+ if (!sessionId) missingFields.push('sessionId');
+ if (!signature) missingFields.push('signature');
+ if (!publicKey) missingFields.push('publicKey');
+ if (!message) missingFields.push('message');
+
+ if (missingFields.length > 0) {
+ return res.status(400).json({
+ error: `Missing required fields: ${missingFields.join(', ')}`
+ });
+ }
+
+ // Process the signed payload
+ const result = await this.ensureService().processSignedPayload(
+ sessionId,
+ signature,
+ publicKey,
+ message
+ );
+
+ res.json({
+ success: true,
+ message: "Signature verified and charter signed",
+ data: result
+ });
+
+ } catch (error) {
+ console.error("Error processing signed payload:", error);
+ res.status(500).json({ error: "Failed to process signed payload" });
+ }
+ }
+
+ // Get signing session by ID
+ async getSigningSession(req: Request, res: Response) {
+ try {
+ const { sessionId } = req.params;
+
+ if (!sessionId) {
+ return res.status(400).json({ error: "Session ID is required" });
+ }
+
+ const session = await this.ensureService().getSession(sessionId);
+
+ if (!session) {
+ return res.status(404).json({ error: "Session not found" });
+ }
+
+ res.json(session);
+
+ } catch (error) {
+ console.error("Error getting signing session:", error);
+ res.status(500).json({ error: "Failed to get signing session" });
+ }
+ }
+}
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/controllers/GroupController.ts b/platforms/group-charter-manager-api/src/controllers/GroupController.ts
index 765c2baf..fd3e34ed 100644
--- a/platforms/group-charter-manager-api/src/controllers/GroupController.ts
+++ b/platforms/group-charter-manager-api/src/controllers/GroupController.ts
@@ -1,8 +1,10 @@
import { Request, Response } from "express";
import { GroupService } from "../services/GroupService";
+import { CharterSignatureService } from "../services/CharterSignatureService";
export class GroupController {
private groupService = new GroupService();
+ private charterSignatureService = new CharterSignatureService();
async createGroup(req: Request, res: Response) {
try {
@@ -100,10 +102,11 @@ export class GroupController {
return res.status(404).json({ error: "Group not found" });
}
- // Check if user is a participant in the group
- const isParticipant = group.participants?.some(p => p.id === userId);
- if (!isParticipant) {
- return res.status(403).json({ error: "Access denied - you must be a participant in this group" });
+ // Check if user is an admin or owner of the group
+ const isOwner = group.owner === userId;
+ const isAdmin = group.admins?.includes(userId);
+ if (!isOwner && !isAdmin) {
+ return res.status(403).json({ error: "Access denied - only admins can edit the charter" });
}
const { charter } = req.body;
@@ -123,6 +126,25 @@ export class GroupController {
async deleteGroup(req: Request, res: Response) {
try {
const { id } = req.params;
+ const userId = (req as any).user?.id;
+
+ if (!userId) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ const group = await this.groupService.getGroupById(id);
+ if (!group) {
+ return res.status(404).json({ error: "Group not found" });
+ }
+
+ // Check if user is owner or admin
+ const isOwner = group.owner === userId;
+ const isAdmin = group.admins?.includes(userId);
+
+ if (!isOwner && !isAdmin) {
+ return res.status(403).json({ error: "Access denied - only admins can delete groups" });
+ }
+
const success = await this.groupService.deleteGroup(id);
if (!success) {
@@ -251,24 +273,30 @@ export class GroupController {
}
}
- /**
- * Admin endpoint to ensure Cerberus monitoring is set up for all groups
- */
- async ensureCerberusInAllGroups(req: Request, res: Response) {
+ async getCharterSigningStatus(req: Request, res: Response) {
try {
+ const { id } = req.params;
const userId = (req as any).user?.id;
+
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
- // This is an admin-only operation - you might want to add more specific admin checks
- console.log(`User ${userId} requested to set up Cerberus monitoring for all groups`);
-
- await this.groupService.ensureCerberusInAllGroups();
-
- res.json({ message: "Cerberus monitoring has been set up for all groups with charter requirements" });
+ const group = await this.groupService.getGroupById(id);
+ if (!group) {
+ return res.status(404).json({ error: "Group not found" });
+ }
+
+ // Check if user is a participant in the group
+ const isParticipant = group.participants?.some(p => p.id === userId);
+ if (!isParticipant) {
+ return res.status(403).json({ error: "Access denied - you must be a participant in this group" });
+ }
+
+ const signingStatus = await this.charterSignatureService.getGroupSigningStatus(id);
+ res.json(signingStatus);
} catch (error) {
- console.error("Error setting up Cerberus monitoring for all groups:", error);
+ console.error("Error getting charter signing status:", error);
res.status(500).json({ error: "Internal server error" });
}
}
diff --git a/platforms/group-charter-manager-api/src/database/data-source.ts b/platforms/group-charter-manager-api/src/database/data-source.ts
index 1473864e..52d84a6d 100644
--- a/platforms/group-charter-manager-api/src/database/data-source.ts
+++ b/platforms/group-charter-manager-api/src/database/data-source.ts
@@ -6,6 +6,7 @@ import { Group } from "./entities/Group";
import { Message } from "./entities/Message";
import { PostgresSubscriber } from "../web3adapter/watchers/subscriber";
import path from "path";
+import { CharterSignature } from "./entities/CharterSignature";
config({ path: path.resolve(__dirname, "../../../../.env") });
@@ -14,7 +15,7 @@ export const AppDataSource = new DataSource({
url: process.env.GROUP_CHARTER_DATABASE_URL,
synchronize: false,
logging: process.env.NODE_ENV === "development",
- entities: [User, Group, Message],
+ entities: [User, Group, Message, CharterSignature],
migrations: ["src/database/migrations/*.ts"],
subscribers: [PostgresSubscriber],
});
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/database/entities/CharterSignature.ts b/platforms/group-charter-manager-api/src/database/entities/CharterSignature.ts
new file mode 100644
index 00000000..5c89e9c8
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/database/entities/CharterSignature.ts
@@ -0,0 +1,49 @@
+import {
+ Entity,
+ CreateDateColumn,
+ UpdateDateColumn,
+ PrimaryGeneratedColumn,
+ Column,
+ ManyToOne,
+ JoinColumn,
+} from "typeorm";
+import { Group } from "./Group";
+import { User } from "./User";
+
+@Entity()
+export class CharterSignature {
+ @PrimaryGeneratedColumn("uuid")
+ id!: string;
+
+ @Column()
+ groupId!: string;
+
+ @Column()
+ userId!: string;
+
+ @Column({ type: "text" })
+ charterHash!: string; // Hash of the charter content to track versions
+
+ @Column({ type: "text" })
+ signature!: string; // Cryptographic signature
+
+ @Column({ type: "text" })
+ publicKey!: string; // User's public key
+
+ @Column({ type: "text" })
+ message!: string; // Original message that was signed
+
+ @ManyToOne(() => Group)
+ @JoinColumn({ name: "groupId" })
+ group!: Group;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: "userId" })
+ user!: User;
+
+ @CreateDateColumn()
+ createdAt!: Date;
+
+ @UpdateDateColumn()
+ updatedAt!: Date;
+}
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/database/entities/Group.ts b/platforms/group-charter-manager-api/src/database/entities/Group.ts
index 2290738a..48fc7c1e 100644
--- a/platforms/group-charter-manager-api/src/database/entities/Group.ts
+++ b/platforms/group-charter-manager-api/src/database/entities/Group.ts
@@ -9,6 +9,7 @@ import {
JoinTable,
} from "typeorm";
import { Message } from "./Message";
+import { CharterSignature } from "./CharterSignature";
@Entity()
export class Group {
@@ -41,6 +42,9 @@ export class Group {
@OneToMany(() => Message, (message) => message.group)
messages!: Message[];
+ @OneToMany(() => CharterSignature, (signature) => signature.group)
+ charterSignatures!: CharterSignature[];
+
@CreateDateColumn()
createdAt!: Date;
diff --git a/platforms/group-charter-manager-api/src/database/migrations/1755598750354-migration.ts b/platforms/group-charter-manager-api/src/database/migrations/1755598750354-migration.ts
new file mode 100644
index 00000000..8cb4fa45
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/database/migrations/1755598750354-migration.ts
@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class Migration1755598750354 implements MigrationInterface {
+ name = 'Migration1755598750354'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE TABLE "charter_signature" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "groupId" uuid NOT NULL, "userId" uuid NOT NULL, "charterHash" text NOT NULL, "signature" text NOT NULL, "publicKey" text NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d566749a8805a43c54ad028deef" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`ALTER TABLE "charter_signature" ADD CONSTRAINT "FK_e1f768c9d467cd20b0f45321626" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "charter_signature" ADD CONSTRAINT "FK_fb0db27afde8d484139b66628fd" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "charter_signature" DROP CONSTRAINT "FK_fb0db27afde8d484139b66628fd"`);
+ await queryRunner.query(`ALTER TABLE "charter_signature" DROP CONSTRAINT "FK_e1f768c9d467cd20b0f45321626"`);
+ await queryRunner.query(`DROP TABLE "charter_signature"`);
+ }
+
+}
diff --git a/platforms/group-charter-manager-api/src/index.ts b/platforms/group-charter-manager-api/src/index.ts
index 4bc7ac13..af27abde 100644
--- a/platforms/group-charter-manager-api/src/index.ts
+++ b/platforms/group-charter-manager-api/src/index.ts
@@ -7,6 +7,7 @@ import { UserController } from "./controllers/UserController";
import { GroupController } from "./controllers/GroupController";
import { WebhookController } from "./controllers/WebhookController";
import { AuthController } from "./controllers/AuthController";
+import { CharterSigningController } from "./controllers/CharterSigningController";
import { authMiddleware, authGuard } from "./middleware/auth";
import { adapter } from "./web3adapter";
import path from "path";
@@ -20,6 +21,14 @@ const port = process.env.PORT || 3001;
AppDataSource.initialize()
.then(async () => {
console.log("Database connection established");
+
+ // Initialize signing controller
+ try {
+ charterSigningController = new CharterSigningController();
+ console.log("CharterSigningController initialized successfully");
+ } catch (error) {
+ console.error("Failed to initialize CharterSigningController:", error);
+ }
})
.catch((error: any) => {
console.error("Error during initialization:", error);
@@ -46,12 +55,40 @@ const userController = new UserController();
const groupController = new GroupController();
const webhookController = new WebhookController(adapter);
const authController = new AuthController();
+let charterSigningController: CharterSigningController | null = null;
// Public routes (no auth required)
app.get("/api/health", (req, res) => {
res.json({ status: "ok", service: "group-charter-manager-api" });
});
+// Test endpoint to verify signing service is working
+app.get("/api/signing/test", (req, res) => {
+ try {
+ if (!charterSigningController) {
+ return res.json({
+ status: "error",
+ error: "Signing service not ready",
+ signing: "initializing"
+ });
+ }
+
+ const testResult = charterSigningController.testConnection();
+
+ res.json({
+ status: "ok",
+ message: "Signing service is working",
+ signing: testResult ? "ready" : "error"
+ });
+ } catch (error) {
+ res.json({
+ status: "error",
+ error: "Signing service test failed",
+ signing: "error"
+ });
+ }
+});
+
// Auth routes (no auth required)
app.get("/api/auth/offer", authController.getOffer.bind(authController));
app.post("/api/auth", authController.login.bind(authController));
@@ -60,6 +97,22 @@ app.get("/api/auth/sessions/:id", authController.sseStream.bind(authController))
// Webhook route (no auth required)
app.post("/api/webhook", webhookController.handleWebhook.bind(webhookController));
+// SSE endpoint for signing status (public - no auth required for real-time updates)
+app.get("/api/signing/sessions/:sessionId/status", (req, res) => {
+ if (!charterSigningController) {
+ return res.status(503).json({ error: "Signing service not ready" });
+ }
+ charterSigningController.getSigningSessionStatus(req, res);
+});
+
+// Signing callback endpoint (public - called by eID Wallet)
+app.post("/api/signing/callback", (req, res) => {
+ if (!charterSigningController) {
+ return res.status(503).json({ error: "Signing service not ready" });
+ }
+ charterSigningController.handleSignedPayload(req, res);
+});
+
// Apply auth middleware to all routes below
app.use(authMiddleware);
@@ -83,16 +136,30 @@ app.delete("/api/groups/:id", authGuard, groupController.deleteGroup.bind(groupC
// Charter routes
app.put("/api/groups/:id/charter", authGuard, groupController.updateCharter.bind(groupController));
+app.get("/api/groups/:id/charter/signing-status", authGuard, groupController.getCharterSigningStatus.bind(groupController));
+
+// Signing routes (protected - require authentication)
+app.post("/api/signing/sessions", authGuard, (req, res) => {
+ if (!charterSigningController) {
+ return res.status(503).json({ error: "Signing service not ready" });
+ }
+ charterSigningController.createSigningSession(req, res);
+});
+
+app.get("/api/signing/sessions/:sessionId", authGuard, (req, res) => {
+ if (!charterSigningController) {
+ return res.status(503).json({ error: "Signing service not ready" });
+ }
+ charterSigningController.getSigningSession(req, res);
+});
// Group participant routes
app.get("/api/groups/:groupId", authGuard, groupController.getGroup.bind(groupController));
app.post("/api/groups/:groupId/participants", authGuard, groupController.addParticipants.bind(groupController));
app.delete("/api/groups/:groupId/participants/:userId", authGuard, groupController.removeParticipant.bind(groupController));
-// Admin routes
-app.post("/api/admin/ensure-cerberus", authGuard, groupController.ensureCerberusInAllGroups.bind(groupController));
-
// Start server
app.listen(port, () => {
console.log(`Group Charter Manager API running on port ${port}`);
+ console.log(`Signing service status: ${charterSigningController ? "ready" : "initializing"}`);
});
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/services/CharterSignatureService.ts b/platforms/group-charter-manager-api/src/services/CharterSignatureService.ts
new file mode 100644
index 00000000..74dbcd35
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/services/CharterSignatureService.ts
@@ -0,0 +1,142 @@
+import { AppDataSource } from "../database/data-source";
+import { CharterSignature } from "../database/entities/CharterSignature";
+import { Group } from "../database/entities/Group";
+import { User } from "../database/entities/User";
+import crypto from "crypto";
+
+export class CharterSignatureService {
+ private signatureRepository = AppDataSource.getRepository(CharterSignature);
+ private groupRepository = AppDataSource.getRepository(Group);
+ private userRepository = AppDataSource.getRepository(User);
+
+ // Create a hash of the charter content to track versions
+ createCharterHash(charterContent: string): string {
+ return crypto.createHash('sha256').update(charterContent).digest('hex');
+ }
+
+ // Record a new signature
+ async recordSignature(
+ groupId: string,
+ userId: string,
+ charterContent: string,
+ signature: string,
+ publicKey: string,
+ message: string
+ ): Promise {
+ const charterHash = this.createCharterHash(charterContent);
+
+ const charterSignature = this.signatureRepository.create({
+ groupId,
+ userId,
+ charterHash,
+ signature,
+ publicKey,
+ message
+ });
+
+ return await this.signatureRepository.save(charterSignature);
+ }
+
+ // Get all signatures for a specific charter version
+ async getSignaturesForCharter(groupId: string, charterContent: string): Promise {
+ const charterHash = this.createCharterHash(charterContent);
+
+ return await this.signatureRepository.find({
+ where: {
+ groupId,
+ charterHash
+ },
+ relations: ['user'],
+ order: {
+ createdAt: 'ASC'
+ }
+ });
+ }
+
+ // Get all signatures for a group (latest version)
+ async getLatestSignaturesForGroup(groupId: string): Promise {
+ const group = await this.groupRepository.findOne({
+ where: { id: groupId },
+ select: ['charter']
+ });
+
+ if (!group?.charter) {
+ return [];
+ }
+
+ return await this.getSignaturesForCharter(groupId, group.charter);
+ }
+
+ // Check if a user has signed the current charter version
+ async hasUserSignedCharter(groupId: string, userId: string, charterContent: string): Promise {
+ const charterHash = this.createCharterHash(charterContent);
+
+ const signature = await this.signatureRepository.findOne({
+ where: {
+ groupId,
+ userId,
+ charterHash
+ }
+ });
+
+ return !!signature;
+ }
+
+ // Get signing status for all participants in a group
+ async getGroupSigningStatus(groupId: string): Promise<{
+ participants: any[];
+ signatures: CharterSignature[];
+ charterHash: string;
+ isSigned: boolean;
+ }> {
+ const group = await this.groupRepository.findOne({
+ where: { id: groupId },
+ relations: ['participants']
+ });
+
+ if (!group) {
+ throw new Error("Group not found");
+ }
+
+ const charterHash = group.charter ? this.createCharterHash(group.charter) : "";
+ const signatures = charterHash ? await this.getSignaturesForCharter(groupId, group.charter) : [];
+
+ // Create a map of signed user IDs for quick lookup
+ const signedUserIds = new Set(signatures.map(s => s.userId));
+
+ // Add signing status and role information to each participant
+ const participantsWithStatus = group.participants.map(participant => ({
+ ...participant,
+ hasSigned: signedUserIds.has(participant.id),
+ isAdmin: group.admins?.includes(participant.id) || false,
+ isOwner: group.owner === participant.id
+ }));
+
+ return {
+ participants: participantsWithStatus,
+ signatures,
+ charterHash,
+ isSigned: signatures.length > 0
+ };
+ }
+
+ // Get all signatures for a user across all groups
+ async getUserSignatures(userId: string): Promise {
+ return await this.signatureRepository.find({
+ where: { userId },
+ relations: ['group'],
+ order: {
+ createdAt: 'DESC'
+ }
+ });
+ }
+
+ // Delete all signatures for a group (when charter content changes)
+ async deleteAllSignaturesForGroup(groupId: string): Promise {
+ const result = await this.signatureRepository.delete({
+ groupId
+ });
+
+ console.log(`Deleted ${result.affected || 0} signatures for group ${groupId} due to charter content change`);
+ }
+}
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/services/CharterSigningService.ts b/platforms/group-charter-manager-api/src/services/CharterSigningService.ts
new file mode 100644
index 00000000..40cabe66
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/services/CharterSigningService.ts
@@ -0,0 +1,157 @@
+import crypto from "crypto";
+import { CharterSignatureService } from "./CharterSignatureService";
+
+export interface CharterSigningSession {
+ sessionId: string;
+ groupId: string;
+ charterData: any;
+ userId: string;
+ qrData: string;
+ createdAt: Date;
+ expiresAt: Date;
+ status: "pending" | "signed" | "expired" | "completed";
+}
+
+export interface SignedCharterPayload {
+ sessionId: string;
+ signature: string;
+ publicKey: string;
+ message: string;
+}
+
+export interface CharterSigningResult {
+ success: boolean;
+ sessionId: string;
+ groupId: string;
+ userId: string;
+ signature: string;
+ publicKey: string;
+ message: string;
+ type: "signed";
+}
+
+export class CharterSigningService {
+ private sessions: Map = new Map();
+ private signatureService = new CharterSignatureService();
+
+ async createSession(groupId: string, charterData: any, userId: string): Promise {
+ const sessionId = crypto.randomUUID();
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + 15 * 60 * 1000); // 15 minutes
+
+ // Create generic signature request data
+ const messageData = JSON.stringify({
+ message: `Sign charter for group: ${groupId}`,
+ sessionId: sessionId
+ });
+
+ const base64Data = Buffer.from(messageData).toString('base64');
+ const apiBaseUrl = process.env.PUBLIC_GROUP_CHARTER_BASE_URL || "http://localhost:3003";
+ const redirectUri = `${apiBaseUrl}/api/signing/callback`;
+
+ const qrData = `w3ds://sign?session=${sessionId}&data=${base64Data}&redirect_uri=${encodeURIComponent(redirectUri)}`;
+
+ const session: CharterSigningSession = {
+ sessionId,
+ groupId,
+ charterData,
+ userId,
+ qrData,
+ createdAt: now,
+ expiresAt,
+ status: "pending"
+ };
+
+ this.sessions.set(sessionId, session);
+ console.log(`Created session ${sessionId}, total sessions: ${this.sessions.size}`);
+
+ // Set up expiration cleanup
+ setTimeout(() => {
+ const session = this.sessions.get(sessionId);
+ if (session && session.status === "pending") {
+ session.status = "expired";
+ this.sessions.set(sessionId, session);
+ }
+ }, 15 * 60 * 1000);
+
+ return session;
+ }
+
+ async getSession(sessionId: string): Promise {
+ const session = this.sessions.get(sessionId);
+
+ if (!session) {
+ return null;
+ }
+
+ // Check if session has expired
+ if (session.status === "pending" && new Date() > session.expiresAt) {
+ session.status = "expired";
+ this.sessions.set(sessionId, session);
+ }
+
+ return session;
+ }
+
+ async processSignedPayload(sessionId: string, signature: string, publicKey: string, message: string): Promise {
+ console.log(`Processing signed payload for session: ${sessionId}`);
+ console.log(`Available sessions:`, Array.from(this.sessions.keys()));
+
+ const session = await this.getSession(sessionId);
+
+ if (!session) {
+ console.log(`Session ${sessionId} not found in available sessions`);
+ throw new Error("Session not found");
+ }
+
+ if (session.status !== "pending") {
+ throw new Error("Session is not in pending state");
+ }
+
+ // Verify the signature (basic verification for now)
+ // In a real implementation, you would verify the cryptographic signature
+ if (!signature || !publicKey || !message) {
+ throw new Error("Invalid signature data");
+ }
+
+ // Record the signature in the database
+ try {
+ await this.signatureService.recordSignature(
+ session.groupId,
+ session.userId,
+ session.charterData.charter,
+ signature,
+ publicKey,
+ message
+ );
+ } catch (error) {
+ console.error("Failed to record signature:", error);
+ throw new Error("Failed to record signature");
+ }
+
+ // Update session status
+ session.status = "completed";
+ this.sessions.set(sessionId, session);
+
+ const result: CharterSigningResult = {
+ success: true,
+ sessionId,
+ groupId: session.groupId,
+ userId: session.userId,
+ signature,
+ publicKey,
+ message,
+ type: "signed"
+ };
+
+ return result;
+ }
+
+ async getSessionStatus(sessionId: string): Promise {
+ return this.getSession(sessionId);
+ }
+
+ testConnection(): boolean {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/platforms/group-charter-manager-api/src/services/GroupService.ts b/platforms/group-charter-manager-api/src/services/GroupService.ts
index 348184a8..62235675 100644
--- a/platforms/group-charter-manager-api/src/services/GroupService.ts
+++ b/platforms/group-charter-manager-api/src/services/GroupService.ts
@@ -2,11 +2,13 @@ import { AppDataSource } from "../database/data-source";
import { Group } from "../database/entities/Group";
import { User } from "../database/entities/User";
import { MessageService } from "./MessageService";
+import { CharterSignatureService } from "./CharterSignatureService";
export class GroupService {
public groupRepository = AppDataSource.getRepository(Group);
private userRepository = AppDataSource.getRepository(User);
private messageService = new MessageService();
+ private charterSignatureService = new CharterSignatureService();
async createGroup(groupData: Partial): Promise {
const group = this.groupRepository.create(groupData);
@@ -26,6 +28,18 @@ export class GroupService {
}
async updateGroup(id: string, groupData: Partial): Promise {
+ // If updating the charter, we need to delete all existing signatures
+ // since the charter content has changed
+ if (groupData.charter !== undefined) {
+ // Get the current group to check if charter is being updated
+ const currentGroup = await this.getGroupById(id);
+ if (currentGroup && currentGroup.charter !== groupData.charter) {
+ // Charter content has changed, so delete all existing signatures
+ console.log(`Charter updated for group ${id}, deleting all existing signatures`);
+ await this.charterSignatureService.deleteAllSignaturesForGroup(id);
+ }
+ }
+
await this.groupRepository.update(id, groupData);
return await this.getGroupById(id);
}
@@ -45,7 +59,7 @@ export class GroupService {
});
}
- async getUserGroups(userId: string): Promise {
+ async getUserGroups(userId: string): Promise {
console.log("Getting groups for user:", userId);
// First, let's get all groups and filter manually to debug
@@ -69,8 +83,27 @@ export class GroupService {
return isUserParticipant && hasMinimumParticipants;
});
- console.log("User groups found (with minimum 3 participants):", userGroups.length);
- return userGroups;
+ // Add signing status for each group
+ const groupsWithSigningStatus = await Promise.all(userGroups.map(async (group) => {
+ // Check if user has signed the charter (if one exists)
+ let hasSigned = false;
+ if (group.charter && group.charter.trim() !== '') {
+ try {
+ hasSigned = await this.charterSignatureService.hasUserSignedCharter(group.id, userId, group.charter);
+ } catch (error) {
+ console.error(`Error checking signing status for group ${group.id}:`, error);
+ hasSigned = false;
+ }
+ }
+
+ return {
+ ...group,
+ hasSigned
+ };
+ }));
+
+ console.log("User groups found (with minimum 3 participants):", groupsWithSigningStatus.length);
+ return groupsWithSigningStatus;
}
diff --git a/platforms/group-charter-manager-api/src/web3adapter/mappings/charter_signature.mapping.json b/platforms/group-charter-manager-api/src/web3adapter/mappings/charter_signature.mapping.json
new file mode 100644
index 00000000..42ee93f8
--- /dev/null
+++ b/platforms/group-charter-manager-api/src/web3adapter/mappings/charter_signature.mapping.json
@@ -0,0 +1,16 @@
+{
+ "tableName": "charter_signatures",
+ "schemaId": "1d83fada-581d-49b0-b6f5-1fe0766da34f",
+ "ownerEnamePath": "users(userId.ename)",
+ "localToUniversalMap": {
+ "id": "id",
+ "groupId": "groups(groupId),group",
+ "userId": "users(userId.id),user",
+ "charterHash": "charterHash",
+ "signature": "signature",
+ "publicKey": "publicKey",
+ "message": "message",
+ "createdAt": "__date(createdAt)",
+ "updatedAt": "__date(updatedAt)"
+ }
+}
diff --git a/platforms/group-charter-manager-api/src/web3adapter/mappings/group.mapping.json b/platforms/group-charter-manager-api/src/web3adapter/mappings/group.mapping.json
index 37d0d829..39bf9380 100644
--- a/platforms/group-charter-manager-api/src/web3adapter/mappings/group.mapping.json
+++ b/platforms/group-charter-manager-api/src/web3adapter/mappings/group.mapping.json
@@ -10,6 +10,7 @@
"admins": "users(admins),admins",
"charter": "charter",
"participants": "users(participants[].id),participantIds",
+ "charterSignatures": "charter_signatures(charterSignatures[].id),signatureIds",
"createdAt": "createdAt",
"updatedAt": "updatedAt"
}
diff --git a/platforms/group-charter-manager-api/src/web3adapter/watchers/subscriber.ts b/platforms/group-charter-manager-api/src/web3adapter/watchers/subscriber.ts
index 4c6af983..8fea5770 100644
--- a/platforms/group-charter-manager-api/src/web3adapter/watchers/subscriber.ts
+++ b/platforms/group-charter-manager-api/src/web3adapter/watchers/subscriber.ts
@@ -59,6 +59,18 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
enrichedEntity.author = author;
}
+ console.log("ENRICHING,", tableName)
+
+ // Special handling for charter signatures: always load the user and substitute at userId
+ if (tableName === "charter_signature" && entity.userId) {
+ const user = await AppDataSource.getRepository(
+ "User"
+ ).findOne({ where: { id: entity.userId } });
+ if (user) {
+ enrichedEntity.userId = user;
+ }
+ }
+
return this.entityToPlain(enrichedEntity);
} catch (error) {
console.error("Error loading relations:", error);
@@ -335,6 +347,8 @@ console.log("hmm?")
return ["followers", "following"];
case "Group":
return ["participants"];
+ case "CharterSignature":
+ return ["user", "group"];
default:
return [];
}
diff --git a/platforms/group-charter-manager/src/app/charter/[id]/page.tsx b/platforms/group-charter-manager/src/app/charter/[id]/page.tsx
index 81918f89..7138c0e2 100644
--- a/platforms/group-charter-manager/src/app/charter/[id]/page.tsx
+++ b/platforms/group-charter-manager/src/app/charter/[id]/page.tsx
@@ -8,6 +8,7 @@ import {
Users,
Save,
X,
+ CheckCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -20,6 +21,8 @@ import { apiClient } from "@/lib/apiClient";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
+import { CharterSigningStatus } from "@/components/charter-signing-status";
+import { CharterSigningInterface } from "@/components/charter-signing-interface";
interface Group {
id: string;
@@ -43,13 +46,14 @@ export default function CharterDetail({
const [isEditing, setIsEditing] = useState(false);
const [editCharter, setEditCharter] = useState("");
const [isSaving, setIsSaving] = useState(false);
+ const [showSigningInterface, setShowSigningInterface] = useState(false);
+ const [signingStatus, setSigningStatus] = useState(null);
+ const [signingStatusLoading, setSigningStatusLoading] = useState(true);
const { id } = use(params);
const { toast } = useToast();
const { user } = useAuth();
- // Fetch group data on component mount
- useEffect(() => {
const fetchGroup = async () => {
try {
setIsLoading(true);
@@ -68,11 +72,34 @@ export default function CharterDetail({
}
};
+ const fetchSigningStatus = async () => {
+ if (!group?.charter) return;
+
+ try {
+ setSigningStatusLoading(true);
+ const response = await apiClient.get(`/api/groups/${id}/charter/signing-status`);
+ setSigningStatus(response.data);
+ } catch (error) {
+ console.error('Failed to fetch signing status:', error);
+ } finally {
+ setSigningStatusLoading(false);
+ }
+ };
+
+ // Fetch group data on component mount
+ useEffect(() => {
if (id) {
fetchGroup();
}
}, [id, toast]);
+ // Fetch signing status when group changes
+ useEffect(() => {
+ if (group?.charter) {
+ fetchSigningStatus();
+ }
+ }, [group?.charter]);
+
const handleEditStart = () => {
setIsEditing(true);
};
@@ -125,8 +152,8 @@ export default function CharterDetail({
isEditing
});
- // Temporary: Always show edit button for testing
- const showEditButton = true; // canEdit;
+ // Only show edit button for admins
+ const showEditButton = canEdit;
if (isLoading) {
return (
@@ -231,7 +258,7 @@ export default function CharterDetail({
>
) : (
<>
- {/* Edit Charter Button (only for owner or admin) */}
+ {/* Edit Charter Button (only for admins) */}
{showEditButton && (
)}
+
+ {/* Sign Charter Button (only if current user hasn't signed) */}
+ {group.charter && signingStatus && !signingStatusLoading && (
+ (() => {
+ const currentUser = signingStatus.participants.find((p: any) => p.id === user?.id);
+ return currentUser && !currentUser.hasSigned;
+ })() && (
+ setShowSigningInterface(true)}
+ variant="outline"
+ className="bg-green-600 text-white px-6 py-3 rounded-2xl font-medium hover:bg-green-700 transition-all duration-300 shadow-lg"
+ >
+
+ Sign Charter
+
+ )
+ )}
+
+ {/* Disabled button for users who have already signed */}
+ {group.charter && signingStatus && !signingStatusLoading && (
+ (() => {
+ const currentUser = signingStatus.participants.find((p: any) => p.id === user?.id);
+ return currentUser && currentUser.hasSigned;
+ })() && (
+
+
+ Charter Signed
+
+ )
+ )}
>
)}
@@ -274,12 +335,30 @@ export default function CharterDetail({
) : (
+
No charter content has been set for this group.
+ {!canEdit && (
+
+
+ 💡 Only admins can create the charter. Contact a group admin to get started.
+
+
+ )}
+
)}
)}
+
+ {/* Info message for non-admin users */}
+ {!canEdit && group.charter && (
+