diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json new file mode 100644 index 00000000..1e2187ee --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json @@ -0,0 +1,5749 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "00e9b271c52e170bcaa990489c8e0805", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`, `userPublicKey`) REFERENCES `DkgSession`(`id`, `userPublicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "userPublicKey", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId_userPublicKey", + "unique": false, + "columnNames": [ + "sessionId", + "userPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId_userPublicKey` ON `${TABLE_NAME}` (`sessionId`, `userPublicKey`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId", + "userPublicKey" + ], + "referencedColumns": [ + "id", + "userPublicKey" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`, `userPublicKey`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "userPublicKey" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '00e9b271c52e170bcaa990489c8e0805')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 6fb73be9..0257bc68 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -179,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 21, + version = 22, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -332,6 +332,19 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // would mean a column holding *one* of several parents, which is worse // than unused. See MIGRATION_20_21 and // docs/subgroups-multiple-parents.md. + // + // v22 keys a DkgSession by the profile that ran the ritual as well as by the + // ceremony, and gives DkgParticipantMessage the profile that received each + // message. A ceremony id is minted by its coordinator and travels on the + // wire, so it is the same string for every participant -- including two + // profiles on one device, which is two participants and was one row. The + // second of them adopted the first's host key, its randomness and in the end + // its share. + // + // Changing a primary key is not a shape Room migrates, and the column added + // to the messages is NOT NULL off a value read from another table, so this is + // manual too and passed to the builder rather than listed here. See + // MIGRATION_21_22. ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 212e6c8e..a98fd9d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,10 +3,11 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 -import press.mantra.compose.database.migrations.MIGRATION_20_21 import press.mantra.compose.database.migrations.MIGRATION_9_10 import press.mantra.compose.database.migrations.MIGRATION_13_14 import press.mantra.compose.database.migrations.MIGRATION_14_15 +import press.mantra.compose.database.migrations.MIGRATION_20_21 +import press.mantra.compose.database.migrations.MIGRATION_21_22 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -20,20 +21,22 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These five move data rather than + // Everything else Room generates itself. These six move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites // chat rows, 9->10 copies a session's per-event columns onto the items // table before dropping them, 13->14 renames a column and rewrites the // chat rows that named it the old way, 14->15 adds a column and deletes // the chat rows written for messages from before this device joined, and // 20->21 copies each room's single parentage onto the table that can hold - // several before dropping the columns it came from. + // several before dropping the columns it came from, and 21->22 rebuilds the + // two ritual tables to key them by profile. .addMigrations( MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15, - MIGRATION_20_21 + MIGRATION_20_21, + MIGRATION_21_22, ) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index 4f542730..290f8e15 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -9,23 +9,70 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import kotlinx.coroutines.flow.Flow +/** + * Every read here names a profile, and that is not defensive repetition. + * + * `DkgSession`'s key is (`id`, `userPublicKey`) -- see the note on its entity -- + * so a query that gives only the ceremony id, or only the room, does not identify + * a row. It would return whichever profile's the database happened to order first, + * and what it would hand back is that profile's host key, randomness and share. + * There is deliberately no unscoped overload to reach for. + */ @Dao interface DkgSessionDao { - @Query("SELECT * FROM DkgSession WHERE id = :sessionId") - suspend fun getSessionById(sessionId: String): DkgSession? + @Query("SELECT * FROM DkgSession WHERE id = :sessionId AND userPublicKey = :userPublicKey") + suspend fun getSessionById(sessionId: String, userPublicKey: HexKey): DkgSession? - @Query("SELECT * FROM DkgSession WHERE id = :sessionId") - fun observeSessionById(sessionId: String): Flow + @Query("SELECT * FROM DkgSession WHERE id = :sessionId AND userPublicKey = :userPublicKey") + fun observeSessionById(sessionId: String, userPublicKey: HexKey): Flow + + /** + * The key a ceremony produced, whichever profile is asking -- the one read here + * that names no profile, and says so in its name. + * + * The scoping rule above is about rows that carry secrets. This projects a + * single public column: the threshold key is the ceremony's published result and + * is the same value on every participant's row by construction, so there is + * nothing for a profile to disambiguate and nothing of anyone's to leak. What a + * caller gets is what every member of the group already has. + * + * It exists because the alternative was worse in both directions. Scoping it + * would make a screen resolve nothing while no profile is open, for a fact that + * does not depend on one; leaving `getSessionById` unscoped would hand back a + * whole row -- host key, randomness, share -- to answer a question about a + * public key. + * + * Any row of the ceremony answers, so it takes one. A ceremony that has not + * finished has the column null on every row and answers null, which the callers + * want: both of them run from before it finishes. + */ + @Query( + "SELECT thresholdPublicKey FROM DkgSession WHERE id = :sessionId AND " + + "thresholdPublicKey IS NOT NULL LIMIT 1" + ) + suspend fun getCeremonyThresholdPublicKey(sessionId: String): HexKey? /** * The ritual a room is currently running, newest first. A group may have * abandoned earlier attempts; the live one is the most recent. */ - @Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") - fun observeLatestSessionForChatRoom(chatRoomId: String): Flow + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " + + "ORDER BY createdAt DESC LIMIT 1" + ) + fun observeLatestSessionForChatRoom( + chatRoomId: String, + userPublicKey: HexKey + ): Flow - @Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") - suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " + + "ORDER BY createdAt DESC LIMIT 1" + ) + suspend fun getLatestSessionForChatRoom( + chatRoomId: String, + userPublicKey: HexKey + ): DkgSession? /** * The room's newest ceremony **of its own**, newest first -- never one it is @@ -45,12 +92,20 @@ interface DkgSessionDao { * abused in is a member *excluding* a ceremony from this answer, and a * ceremony they can exclude is one they proposed, whose key they would * simply not have proposed at all. + * + * Scoped to [userPublicKey] because a room's rows are not: `ChatRoom` is keyed + * by the group id alone, so two profiles on one device that are both in a group + * hold their ceremonies of it side by side in one table. Only the asking + * profile's own row carries a share it can sign with. */ @Query( "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND parentChatRoomId IS NULL " + - "ORDER BY createdAt DESC LIMIT 1" + "AND userPublicKey = :userPublicKey ORDER BY createdAt DESC LIMIT 1" ) - suspend fun getLatestOwnSessionForChatRoom(chatRoomId: String): DkgSession? + suspend fun getLatestOwnSessionForChatRoom( + chatRoomId: String, + userPublicKey: HexKey + ): DkgSession? /** * The room's newest ceremony *for one purpose*: the group's own when @@ -72,19 +127,29 @@ interface DkgSessionDao { */ @Query( "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "userPublicKey = :userPublicKey AND " + "(parentChatRoomId = :parentChatRoomId OR " + "(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " + "ORDER BY createdAt DESC LIMIT 1" ) - suspend fun getLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): DkgSession? + suspend fun getLatestSessionFor( + chatRoomId: String, + parentChatRoomId: String?, + userPublicKey: HexKey + ): DkgSession? @Query( "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "userPublicKey = :userPublicKey AND " + "(parentChatRoomId = :parentChatRoomId OR " + "(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " + "ORDER BY createdAt DESC LIMIT 1" ) - fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow + fun observeLatestSessionFor( + chatRoomId: String, + parentChatRoomId: String?, + userPublicKey: HexKey + ): Flow /** * The newest ceremony making a subgroup of [parentChatRoomId] over exactly @@ -111,13 +176,15 @@ interface DkgSessionDao { @Query( "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + "parentChatRoomId = :parentChatRoomId AND " + - "participantPublicKeys = :participantPublicKeys " + + "participantPublicKeys = :participantPublicKeys AND " + + "userPublicKey = :userPublicKey " + "ORDER BY createdAt DESC LIMIT 1" ) suspend fun getLatestSubgroupSessionFor( chatRoomId: String, parentChatRoomId: String, - participantPublicKeys: String + participantPublicKeys: String, + userPublicKey: HexKey ): DkgSession? /** @@ -127,18 +194,34 @@ interface DkgSessionDao { * group, so the standing "this needs you" notice in the transcript cannot ask * about "the room's ceremony" -- it has to look at all of them and say which. */ - @Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") - fun observeSessionsForChatRoom(chatRoomId: String): Flow> + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " + + "ORDER BY createdAt DESC" + ) + fun observeSessionsForChatRoom( + chatRoomId: String, + userPublicKey: HexKey + ): Flow> /** - * Every ceremony this device came out of holding a share, newest first. + * Every ceremony [userPublicKey] came out of holding a share, newest first. * * Signing happens in the admin room, whose id is derived from the key * rather than from the room the ceremony ran in, so the key is found by * matching that derivation rather than by a stored room id. + * + * One profile's, not the device's. A device holds as many of these as it has + * profiles, and a share is only usable by the profile whose ceremony produced + * it: the secret here reconstructs *that* participant's share, and the signer + * id a session would use is that participant's index. Handing one profile the + * other's ceremony answers "this group can sign" to somebody holding nothing + * to sign with, which the UI reads as permission to offer the action. */ - @Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC") - suspend fun getKeyHoldingSessions(): List + @Query( + "SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL " + + "AND userPublicKey = :userPublicKey ORDER BY createdAt DESC" + ) + suspend fun getKeyHoldingSessions(userPublicKey: HexKey): List /** * Ceremonies opened to make a subgroup of [parentChatRoomId], newest first. @@ -153,8 +236,14 @@ interface DkgSessionDao { * of appearing in this list. It decides which screen to offer, not who may * do what. */ - @Query("SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId ORDER BY createdAt DESC") - suspend fun getByParentChatRoomId(parentChatRoomId: String): List + @Query( + "SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId AND " + + "userPublicKey = :userPublicKey ORDER BY createdAt DESC" + ) + suspend fun getByParentChatRoomId( + parentChatRoomId: String, + userPublicKey: HexKey + ): List @Upsert suspend fun upsert(dkgSession: DkgSession) @@ -162,15 +251,58 @@ interface DkgSessionDao { @Upsert suspend fun upsert(dkgParticipantMessage: DkgParticipantMessage) - @Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY participantPublicKey ASC") - suspend fun getMessagesByKind(sessionId: String, kind: Kind): List + /** + * The messages [userPublicKey] has received of one round, in participant order. + * + * Ordered by sender, which is what makes it usable: the ritual derives each + * participant's FROST id from the bytewise sort of the host keys, so a caller + * reading this back gets the same order every device did. + */ + @Query( + "SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " + + "userPublicKey = :userPublicKey AND kind = :kind ORDER BY participantPublicKey ASC" + ) + suspend fun getMessagesByKind( + sessionId: String, + userPublicKey: HexKey, + kind: Kind + ): List - @Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC") - fun observeMessages(sessionId: String): Flow> + @Query( + "SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " + + "userPublicKey = :userPublicKey ORDER BY createdAt ASC" + ) + fun observeMessages( + sessionId: String, + userPublicKey: HexKey + ): Flow> - @Query("SELECT COUNT(*) FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind") - suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int + /** + * How many of a round's messages [userPublicKey] holds -- which is how the + * ritual knows whether a step can run. + * + * The count is of one profile's rows on purpose. Counting the device's would + * reach the threshold while this profile had heard from nobody. + */ + @Query( + "SELECT COUNT(*) FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " + + "userPublicKey = :userPublicKey AND kind = :kind" + ) + suspend fun countMessagesByKind( + sessionId: String, + userPublicKey: HexKey, + kind: Kind + ): Int - @Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind AND participantPublicKey = :participantPublicKey") - suspend fun getMessage(sessionId: String, kind: Kind, participantPublicKey: HexKey): DkgParticipantMessage? + @Query( + "SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " + + "userPublicKey = :userPublicKey AND kind = :kind AND " + + "participantPublicKey = :participantPublicKey" + ) + suspend fun getMessage( + sessionId: String, + userPublicKey: HexKey, + kind: Kind, + participantPublicKey: HexKey + ): DkgParticipantMessage? } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt new file mode 100644 index 00000000..c6229e57 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigration.kt @@ -0,0 +1,153 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Keys a ritual's row by the profile that ran it, not by the ceremony alone. + * + * `DkgSession.id` is minted by the coordinator and carried on the wire, so it is + * the same string on every device and for every profile taking part. One row per + * ceremony therefore meant one row for two participants whenever two profiles on + * a device were both in a group: `ChillDkgRitualManager.acceptProposal` returned + * the row the first of them wrote, and the second ran the ritual behind the + * first's host key, randomness and share. The key becomes (`id`, `userPublicKey`), + * and `DkgParticipantMessage` gains the receiving profile for the same reason -- + * see that entity for why its rows are a profile's rather than the ceremony's. + * + * Not an `AutoMigration`: Room cannot change a primary key, and the column this + * adds is `NOT NULL` with a value that has to be read from another table. + * + * ### Copy before dropping anything + * + * `DROP TABLE` fires foreign keys. `DkgParticipantMessage` references + * `DkgSession(id)` `ON DELETE CASCADE`, so dropping `DkgSession` while messages + * still point at it deletes every one of them -- the same trap `MIGRATION_9_10` + * documents and avoids. Whether it fires at all depends on whether Room left + * foreign keys enforced around the migration, which is not a thing to depend on + * in either direction. + * + * So the cascade is made harmless rather than avoided: + * + * 1. copy both tables into backups that carry **no foreign key at all**, so + * nothing can cascade into them and no insert can fail a parent check; + * 2. drop `DkgParticipantMessage`, then `DkgSession`; + * 3. recreate both in their new shape and copy back from the backups; + * 4. drop the backups. + * + * Step 1 is what makes this safe, and it is worth being exact about which part + * carries the weight: by the time anything is dropped, every row exists in a table + * that no cascade can reach, so a cascade firing in step 2 destroys only rows that + * are about to be dropped regardless. `DkgSessionPerProfileMigrationJvmTest` runs + * with foreign keys **on** and still passes with the two drops in either order, + * which is the evidence for that. + * + * The child is dropped first anyway. It costs nothing, it keeps the intent legible + * to the next person to touch this, and it means the migration does not rely on the + * backups for correctness as well as for recovery. + * + * ### What the new column is filled with + * + * Each message takes the `userPublicKey` of the session it referenced, which is + * unambiguous precisely because of the bug being fixed: before this there was one + * session row per ceremony, so there is exactly one answer per message. + * + * A message whose session is missing has no answer and is dropped. The old + * foreign key should have made that impossible; if one exists it was written + * while foreign keys were off, and it could not satisfy the new key either -- + * keeping it would mean inventing an owner for somebody else's secret. + */ +val MIGRATION_21_22 = object : Migration(21, 22) { + override suspend fun migrate(connection: SQLiteConnection) { + // 1. Backups, deliberately without constraints of any kind. + connection.execSQL( + "CREATE TABLE `DkgSessionBackup` (" + + "`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, " + + "`coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " + + "`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " + + "`participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, " + + "`hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, " + + "`round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, " + + "`certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, " + + "`publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, " + + "`hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, " + + "`round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, " + + "`parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL)" + ) + connection.execSQL("INSERT INTO `DkgSessionBackup` SELECT * FROM `DkgSession`") + + connection.execSQL( + "CREATE TABLE `DkgParticipantMessageBackup` (" + + "`sessionId` TEXT NOT NULL, `userPublicKey` TEXT, " + + "`participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, " + + "`payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)" + ) + connection.execSQL( + "INSERT INTO `DkgParticipantMessageBackup` " + + "(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`, " + + "`payload`, `createdAt`) " + + "SELECT `m`.`sessionId`, " + + "(SELECT `s`.`userPublicKey` FROM `DkgSession` AS `s` " + + "WHERE `s`.`id` = `m`.`sessionId`), " + + "`m`.`participantPublicKey`, `m`.`kind`, `m`.`payload`, `m`.`createdAt` " + + "FROM `DkgParticipantMessage` AS `m`" + ) + + // 2. The child first, so the parent's drop has nothing to cascade to -- though + // the copies above are what actually make this safe. + connection.execSQL("DROP TABLE `DkgParticipantMessage`") + connection.execSQL("DROP TABLE `DkgSession`") + + // 3. The new shapes. Both DDL strings are the ones Room generates for the + // entities, so the schema Room validates against on open is this one. + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `DkgSession` (" + + "`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, " + + "`coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " + + "`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " + + "`participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, " + + "`hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, " + + "`round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, " + + "`certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, " + + "`publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, " + + "`hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, " + + "`round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, " + + "`parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`, `userPublicKey`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` " + + "ON `DkgSession` (`chatRoomId`)" + ) + connection.execSQL("INSERT INTO `DkgSession` SELECT * FROM `DkgSessionBackup`") + + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `DkgParticipantMessage` (" + + "`sessionId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " + + "`participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, " + + "`payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " + + "PRIMARY KEY(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`), " + + "FOREIGN KEY(`sessionId`, `userPublicKey`) REFERENCES `DkgSession`(`id`, `userPublicKey`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId_userPublicKey` " + + "ON `DkgParticipantMessage` (`sessionId`, `userPublicKey`)" + ) + connection.execSQL( + "INSERT INTO `DkgParticipantMessage` " + + "SELECT `sessionId`, `userPublicKey`, `participantPublicKey`, `kind`, " + + "`payload`, `createdAt` FROM `DkgParticipantMessageBackup` " + + "WHERE `userPublicKey` IS NOT NULL" + ) + + // 4. + connection.execSQL("DROP TABLE `DkgParticipantMessageBackup`") + connection.execSQL("DROP TABLE `DkgSessionBackup`") + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgParticipantMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgParticipantMessage.kt index 4558336a..13bd2403 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgParticipantMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgParticipantMessage.kt @@ -9,31 +9,49 @@ import kotlin.time.Clock import kotlin.time.Instant /** - * One ritual message from one member, keyed so a duplicate delivery overwrites - * rather than accumulates — relays redeliver, and feeding the same round-1 - * message in twice would make the coordinator's participant list the wrong - * length. + * One ritual message from one member, as one profile received it — keyed so a + * duplicate delivery overwrites rather than accumulates, because relays redeliver + * and feeding the same round-1 message in twice would make the coordinator's + * participant list the wrong length. * * Covers the host key, round 1 and round 2; the coordinator's own broadcasts * live on [DkgSession] because there is only ever one of each. + * + * ### Why the receiving profile is part of the key + * + * The payload is a public protocol value and identical for everyone who sees it, + * so a row looks at first like the ceremony's rather than a profile's. What is + * per-profile is not the bytes but the *having received them*: `DkgSession` + * deliberately stores inputs instead of protocol state, and the messages are + * those inputs, recomputed from alongside that profile's own randomness. A + * profile's view of the ritual is how far it can recompute, and that is what this + * table holds. + * + * It also keeps the cascade. [DkgSession]'s key is (`id`, `userPublicKey`), and a + * foreign key must name a unique parent, so a child keyed by the ceremony alone + * could not reference it at all -- and deleting a room would leave its rituals' + * messages behind. */ @Entity( - primaryKeys = ["sessionId", "participantPublicKey", "kind"], + primaryKeys = ["sessionId", "userPublicKey", "participantPublicKey", "kind"], foreignKeys = [ ForeignKey( entity = DkgSession::class, - parentColumns = ["id"], - childColumns = ["sessionId"], + parentColumns = ["id", "userPublicKey"], + childColumns = ["sessionId", "userPublicKey"], onDelete = ForeignKey.CASCADE, ) ], indices = [ - Index("sessionId"), + Index("sessionId", "userPublicKey"), ], ) data class DkgParticipantMessage( val sessionId: String, + /** Whose profile received it — the [DkgSession] row this belongs to. */ + val userPublicKey: HexKey, + /** The member's nostr public key — who sent it. */ val participantPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt index 9c1abc09..1302a8f7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt @@ -3,7 +3,6 @@ package press.mantra.compose.database.model import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.Index -import androidx.room3.PrimaryKey import press.mantra.compose.database.model.traits.LocalStoreEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.types.DkgApprovalStep @@ -31,6 +30,14 @@ import kotlin.time.Instant * assumption `MlsGroupState` already relies on. */ @Entity( + // One ritual, one row *per profile*. [id] alone cannot be the key: it is minted + // by the coordinator and carried on the wire, so it is the same string on every + // device and for every profile that takes part. Two profiles on one device are + // two participants of the ceremony, each with its own host key, its own + // randomness and its own share, and keying by the ceremony alone let the second + // of them adopt the first's row -- running the ritual behind one host key where + // the protocol has two. + primaryKeys = ["id", "userPublicKey"], foreignKeys = [ ForeignKey( entity = ChatRoom::class, @@ -44,8 +51,13 @@ import kotlin.time.Instant ], ) data class DkgSession( - /** Minted by the coordinator, carried on every message as `dkg_session`. */ - @PrimaryKey + /** + * Minted by the coordinator, carried on every message as `dkg_session`. + * + * The ceremony's identity, not the row's -- see the note on the key above, and + * [userPublicKey] for the other half of it. Every tag that names a ceremony on + * the wire carries this and nothing else, so it stays the value to put there. + */ val id: String, val chatRoomId: String, @@ -53,7 +65,14 @@ data class DkgSession( /** The member running the ritual — by construction, the room's creator. */ val coordinatorPublicKey: HexKey, - /** Whose device this row belongs to, for multi-account support. */ + /** + * Whose profile this row belongs to: the other half of the key. + * + * Not merely which account is open. It says whose host key [hostPublicKey] is, + * whose randomness [round1Random] and [round2AuxRandom] are, and whose share + * [secretShare] is -- so a read of any of those without this column is a read + * of somebody else's secrets. Every query here carries it for that reason. + */ val userPublicKey: HexKey, /** The `t` of t-of-n, taken from the quorum chosen when the group was made. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 3463a4e5..e0d8dad5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -38,12 +38,22 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity import kotlin.time.Clock import kotlin.time.Instant +/** + * @param activeIdentity whose shares [canSign] answers about -- see the same + * parameter on `DatabaseFrostSigningRepository`, which this one's [canSign] + * duplicates for the group detail screen. Defaults to nobody, which can sign + * nothing. + */ class DatabaseChatRepository( private val database: MantraDatabase, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val activeIdentity: StateFlow = MutableStateFlow(null), ): press.mantra.compose.repository.ChatRepository { val logger = Logger.withTag(TAG) @@ -207,7 +217,9 @@ class DatabaseChatRepository( } override suspend fun canSign(chatRoomId: String): Boolean = try { - FrostSigningManager.canSign(database, chatRoomId) + activeIdentity.value?.nostrPublicKey?.let { userPublicKey -> + FrostSigningManager.canSign(database, chatRoomId, userPublicKey) + } ?: false } catch (e: Throwable) { logger.e("Error reading whether $chatRoomId can sign", e) false diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 1353970d..892c9724 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -21,19 +21,39 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import press.mantra.compose.identity.Identity +/** + * @param activeIdentity whose rituals this answers about. A `DkgSession` is keyed by + * the ceremony *and* the profile that ran it, so none of the reads below name a row + * without one -- and the profile is the repository's to supply rather than the + * screen's, which asks about the ritual it is showing and can mean no one else. + * Defaults to nobody, which has no rituals, and is what a test with no identity + * gets. + */ class DatabaseDkgRepository( private val database: MantraDatabase, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val activeIdentity: StateFlow = MutableStateFlow(null), ): DkgRepository { private val logger = Logger.withTag(TAG) + /** The profile asking, or null when nothing is open. */ + private val userPublicKey: HexKey? get() = activeIdentity.value?.nostrPublicKey + override fun observeSessionById(sessionId: String): Flow = - database.dkgSessionDao().observeSessionById(sessionId) + userPublicKey + ?.let { database.dkgSessionDao().observeSessionById(sessionId, it) } + ?: flowOf(null) override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = - database.dkgSessionDao().observeSessionsForChatRoom(chatRoomId) + userPublicKey + ?.let { database.dkgSessionDao().observeSessionsForChatRoom(chatRoomId, it) } + ?: flowOf(emptyList()) override suspend fun roomAwaitingCreation(session: DkgSession): String? { if (session.stage != DkgRitualStage.COMPLETE) return null @@ -49,13 +69,17 @@ class DatabaseDkgRepository( chatRoomId: String, parentChatRoomId: String?, ): Flow = - database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId) + userPublicKey + ?.let { database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId, it) } + ?: flowOf(null) override fun observeMessages(sessionId: String): Flow> = - database.dkgSessionDao().observeMessages(sessionId) + userPublicKey + ?.let { database.dkgSessionDao().observeMessages(sessionId, it) } + ?: flowOf(emptyList()) override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = - database.dkgSessionDao().getLatestSessionForChatRoom(chatRoomId) + userPublicKey?.let { database.dkgSessionDao().getLatestSessionForChatRoom(chatRoomId, it) } override suspend fun proposeRitual( localChatRoom: LocalChatRoom, @@ -124,9 +148,10 @@ class DatabaseDkgRepository( // -- for a room holding only a subgroup's ceremony, asking for the // room's *own* found nothing at all, and a finished subgroup with // a signed key state could never be created. + // The ceremony's own key, not a profile's row: what is wanted is the + // room it derives, which every member agrees on. val subjectRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey + .getCeremonyThresholdPublicKey(dkgSessionId) ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } @@ -180,8 +205,7 @@ class DatabaseDkgRepository( // rather than once: the ceremony has no key until it finishes, // and this flow is running before it does. val subgroupChatRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey + .getCeremonyThresholdPublicKey(dkgSessionId) ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } ?: return@map null @@ -225,6 +249,9 @@ class DatabaseDkgRepository( database = database, localChatRoom = localChatRoom, sessionId = sessionId, + // Whose approval it is. Nobody open approves nothing: the manager + // reads the row for this profile, and there is no row for null. + userPublicKey = userPublicKey ?: return, step = step, nostrPrivateKey = nostrPrivateKey ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index 951bd62b..e63f0c4c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -7,18 +7,30 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.FrostSignerMessage import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.identity.Identity import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.FrostSigningRepository +/** + * @param activeIdentity whose shares [canSign] answers about. A device holds as many + * profiles as the user signed in, and a group one of them holds a share of is a + * group the next is only reading; the question is therefore per-profile and the + * answer is the repository's to scope, not the call site's -- a screen asking "can + * this group sign" means itself, and cannot be made to mean anyone else. Defaults + * to nobody, which can sign nothing, and is what a test with no identity gets. + */ class DatabaseFrostSigningRepository( private val database: MantraDatabase, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val activeIdentity: StateFlow = MutableStateFlow(null), ): FrostSigningRepository { private val logger = Logger.withTag(TAG) @@ -40,8 +52,18 @@ class DatabaseFrostSigningRepository( override suspend fun getSessionById(sessionId: String): FrostSigningSession? = database.frostSigningSessionDao().getSessionById(sessionId) - override suspend fun canSign(chatRoomId: String): Boolean = - FrostSigningManager.canSign(database, chatRoomId) + /** + * Whether the active profile can sign for this room. + * + * Nobody active signs nothing. A read-only profile reaches here too and is + * refused on the share rather than on the private key -- it holds no ceremony, + * so there is nothing to find. + */ + override suspend fun canSign(chatRoomId: String): Boolean { + val userPublicKey = activeIdentity.value?.nostrPublicKey ?: return false + + return FrostSigningManager.canSign(database, chatRoomId, userPublicKey) + } override suspend fun proposeSigning( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 2f80f727..c60eb49b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -207,7 +207,7 @@ object ChillDkgRitualManager { // requested threshold moot -- it settled that question when it opened. val running = if (parentChatRoomId == null) { database.dkgSessionDao() - .getLatestSessionFor(localChatRoom.chatRoom.id, null) + .getLatestSessionFor(localChatRoom.chatRoom.id, null, userPublicKey) ?.takeIf { it.stage != DkgRitualStage.FAILED } } else { // Same parent, same admins -- and still running. A *completed* one is @@ -219,7 +219,8 @@ object ChillDkgRitualManager { database.dkgSessionDao().getLatestSubgroupSessionFor( chatRoomId = localChatRoom.chatRoom.id, parentChatRoomId = parentChatRoomId, - participantPublicKeys = DkgSession.formatParticipants(members).orEmpty() + participantPublicKeys = DkgSession.formatParticipants(members).orEmpty(), + userPublicKey = userPublicKey )?.takeIf { it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE } @@ -362,7 +363,7 @@ object ChillDkgRitualManager { // top of that -- so a round-1 message routinely lands before the // proposal that opens the ritual. The payload is already stored, and // [acceptProposal] replays it once the proposal turns up. - database.dkgSessionDao().getSessionById(sessionId) + database.dkgSessionDao().getSessionById(sessionId, userPublicKey) } if (session == null) { @@ -381,12 +382,13 @@ object ChillDkgRitualManager { database = database, localChatRoom = localChatRoom, sessionId = session.id, + userPublicKey = session.userPublicKey, nostrPrivateKey = nostrPrivateKey ) } /** - * Records a proposal and answers it with this device's host key. Returns the + * Records a proposal and answers it with this profile's host key. Returns the * session, whether it was just created or already known. */ private suspend fun acceptProposal( @@ -397,7 +399,10 @@ object ChillDkgRitualManager { userPublicKey: HexKey, nostrPrivateKey: ByteArray ): DkgSession? { - database.dkgSessionDao().getSessionById(sessionId)?.let { return it } + // This profile's row, not the device's. Two profiles in one group are two + // participants of the ceremony, and the second of them finding the first's + // row here is what made it run behind the first's host key and share. + database.dkgSessionDao().getSessionById(sessionId, userPublicKey)?.let { return it } // The participant set is the proposal's own p-tags plus its sender, NOT // this device's view of the room. `n` is hashed into the session identity, @@ -577,12 +582,17 @@ object ChillDkgRitualManager { // and a replay feeds the whole backlog through here again; the // upsert below absorbs that, but a chat row has no key to absorb it // with. - val known = database.dkgSessionDao() - .getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null + val known = database.dkgSessionDao().getMessage( + sessionId = session.id, + userPublicKey = session.userPublicKey, + kind = innerEvent.kind, + participantPublicKey = innerEvent.pubKey + ) != null database.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = session.id, + userPublicKey = session.userPublicKey, participantPublicKey = innerEvent.pubKey, kind = innerEvent.kind, payload = innerEvent.content @@ -669,9 +679,10 @@ object ChillDkgRitualManager { database: MantraDatabase, localChatRoom: LocalChatRoom, sessionId: String, + userPublicKey: HexKey, nostrPrivateKey: ByteArray ) { - var session = database.dkgSessionDao().getSessionById(sessionId) ?: return + var session = database.dkgSessionDao().getSessionById(sessionId, userPublicKey) ?: return if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return val hostSeckey = deriveHostSecretKey(nostrPrivateKey) @@ -845,7 +856,8 @@ object ChillDkgRitualManager { database: MantraDatabase, session: DkgSession ): List? { - val hostKeys = database.dkgSessionDao().getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY) + val hostKeys = database.dkgSessionDao() + .getMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY) if (hostKeys.size < session.participantCount) { logger.d("Ritual ${session.id}: ${hostKeys.size}/${session.participantCount} host keys") @@ -922,10 +934,11 @@ object ChillDkgRitualManager { kind: Kind ): List? { val hostKeyByParticipant = database.dkgSessionDao() - .getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY) + .getMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY) .associate { it.participantPublicKey to it.payload } - val messages = database.dkgSessionDao().getMessagesByKind(session.id, kind) + val messages = database.dkgSessionDao() + .getMessagesByKind(session.id, session.userPublicKey, kind) if (messages.size < session.participantCount) { logger.d("Ritual ${session.id}: ${messages.size}/${session.participantCount} of kind $kind") return null @@ -949,13 +962,20 @@ object ChillDkgRitualManager { .map { (_, payload) -> payload.hexToByteArray() } } - /** This device's own message of [kind], if it has published one. */ + /** + * This profile's own message of [kind], if it has published one. + * + * The profile is both the row's owner and the sender here, which is why the + * same value appears twice: this is the one message in the ritual that this + * profile both received and wrote. + */ private suspend fun ownMessage( database: MantraDatabase, session: DkgSession, kind: Kind ): DkgParticipantMessage? = database.dkgSessionDao().getMessage( sessionId = session.id, + userPublicKey = session.userPublicKey, kind = kind, participantPublicKey = session.userPublicKey ) @@ -972,7 +992,8 @@ object ChillDkgRitualManager { session: DkgSession, transform: (DkgSession) -> DkgSession ): DkgSession { - val current = database.dkgSessionDao().getSessionById(session.id) ?: session + val current = database.dkgSessionDao() + .getSessionById(session.id, session.userPublicKey) ?: session val updated = transform(current) if (updated == current) return current @@ -1010,7 +1031,8 @@ object ChillDkgRitualManager { // Read before writing so the notice goes out once. A ritual can be failed // from two directions -- a FAILURE message from a member, and a fault raised // locally -- and the group does not need to be told twice. - val current = database.dkgSessionDao().getSessionById(session.id) ?: session + val current = database.dkgSessionDao() + .getSessionById(session.id, session.userPublicKey) ?: session if (current.stage == DkgRitualStage.FAILED) return update(database, current) { @@ -1075,7 +1097,7 @@ object ChillDkgRitualManager { // is computed over every member's host key, so asking earlier would be // asking about a ceremony whose shape is not yet known. val hostKeys = database.dkgSessionDao() - .countMessagesByKind(session.id, DkgRitualEvents.HOST_KEY) + .countMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY) return if (hostKeys >= session.participantCount) DkgApprovalStep.ROUND_1 else null } @@ -1100,10 +1122,13 @@ object ChillDkgRitualManager { database: MantraDatabase, localChatRoom: LocalChatRoom, sessionId: String, + userPublicKey: HexKey, step: DkgApprovalStep, nostrPrivateKey: ByteArray ) { - val session = database.dkgSessionDao().getSessionById(sessionId) ?: return + // Whose approval this is. A ritual id alone names no row, and approving on + // behalf of the other profile on the device would publish its host key. + val session = database.dkgSessionDao().getSessionById(sessionId, userPublicKey) ?: return if (pendingApproval(database, session) != step) { logger.i("Ritual $sessionId is not waiting on $step; ignoring the approval") @@ -1125,6 +1150,7 @@ object ChillDkgRitualManager { database = database, localChatRoom = localChatRoom, sessionId = sessionId, + userPublicKey = userPublicKey, nostrPrivateKey = nostrPrivateKey ) } @@ -1235,7 +1261,7 @@ object ChillDkgRitualManager { /** The stored session, for reading a column back before overwriting it. */ private suspend fun current(database: MantraDatabase, session: DkgSession): DkgSession = - database.dkgSessionDao().getSessionById(session.id) ?: session + database.dkgSessionDao().getSessionById(session.id, session.userPublicKey) ?: session /** * Puts a ritual milestone in the group's chat. @@ -1311,6 +1337,7 @@ object ChillDkgRitualManager { database.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = session.id, + userPublicKey = session.userPublicKey, participantPublicKey = session.userPublicKey, kind = kind, payload = content diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 5dfa9ce4..bea9566d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -160,9 +160,11 @@ object FrostSigningManager { * Everybody else leaves it null and is resolved from the room, which is the * only form that cannot name a ceremony the room has nothing to do with. * - * Even then it is taken only if this device actually holds a share of it, so - * naming one cannot talk the session into signing with material it has not - * got; anything else falls back as though none was given. + * Even then it is taken only if [userPublicKey] actually holds a share of it, + * so naming one cannot talk the session into signing with material it has not + * got -- nor with the material of the other profile on this device, which is + * the same rule read across personas rather than across devices. Anything + * else falls back as though none was given. */ suspend fun proposeSigning( database: MantraDatabase, @@ -244,8 +246,12 @@ object FrostSigningManager { dependents: (lead: Event) -> List>, key: DkgSession? = null ): FrostSigningSession { - val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } - ?: completedKey(database, localChatRoom.chatRoom.id) + val ceremony = key?.takeIf { + it.stage == DkgRitualStage.COMPLETE && + it.secretShare != null && + it.userPublicKey == userPublicKey + } + ?: completedKey(database, localChatRoom.chatRoom.id, userPublicKey) ?: throw IllegalStateException("This group has no shared key to sign with") val signerId = signerIdOf(database, ceremony, userPublicKey) @@ -394,9 +400,13 @@ object FrostSigningManager { return null } - val key = database.dkgSessionDao().getSessionById(dkgSessionId) + // Scoped to the profile being asked. The proposal names a ceremony, which is + // a row per profile that took part; a device holding the other profile's + // would otherwise answer here, and `signerIdOf` below would be looking up + // this profile's index in a ceremony whose share belongs to the other. + val key = database.dkgSessionDao().getSessionById(dkgSessionId, userPublicKey) if (key == null || key.stage != DkgRitualStage.COMPLETE) { - logger.w("Signing proposal $sessionId names key $dkgSessionId, which this device does not hold; dropping") + logger.w("Signing proposal $sessionId names key $dkgSessionId, which this profile does not hold; dropping") return null } @@ -668,7 +678,8 @@ object FrostSigningManager { var session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) return - val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return + val key = database.dkgSessionDao() + .getSessionById(session.dkgSessionId, session.userPublicKey) ?: return val secretShare = key.secretShare?.let { PrivateKey(ByteVector32(it)) } ?: return val thresholdPublicKey = key.thresholdPublicKey ?: return @@ -1163,7 +1174,8 @@ object FrostSigningManager { session: FrostSigningSession, items: Int ): List>>? { - val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null + val key = database.dkgSessionDao() + .getSessionById(session.dkgSessionId, session.userPublicKey) ?: return null val idByMember = signerIds(database, key) val offered = database.frostSigningSessionDao() @@ -1201,7 +1213,8 @@ object FrostSigningManager { signerIds: List, items: Int ): List>? { - val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null + val key = database.dkgSessionDao() + .getSessionById(session.dkgSessionId, session.userPublicKey) ?: return null val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } val payloadByMember = database.frostSigningSessionDao() @@ -1279,7 +1292,7 @@ object FrostSigningManager { key: DkgSession ): Map { val hostKeys = database.dkgSessionDao() - .getMessagesByKind(key.id, DkgRitualEvents.HOST_KEY) + .getMessagesByKind(key.id, key.userPublicKey, DkgRitualEvents.HOST_KEY) .associate { it.participantPublicKey to it.payload.lowercase() } val order = hostKeys.values.sorted() @@ -1337,17 +1350,55 @@ object FrostSigningManager { * through here at all, because it names the ceremony outright: the room it * runs in is the NIP-17 room the ceremony was held in, which signs nothing * else and has no key state of its own. + * + * ### Whose key + * + * [userPublicKey] is the profile asking, and every one of the three answers is + * scoped to it. A device holds several profiles, each with its own ceremonies + * and its own groups, and every one of those rows is in this one table -- + * `ChatRoom` is keyed by the group id alone, so even the rooms are shared. + * Nothing in a room, a key state or a threshold key says which profile is + * asking; only the ceremony row does, in `DkgSession.userPublicKey`. + * + * Unscoped, each answer hands profile B a ceremony belonging to profile A, and + * what comes back is a session whose `secretShare` is A's. B is not a + * participant of it, so [signerIdOf] throws and the user meets a button that + * fails having been offered -- which is the outcome this is read to avoid. It + * is no escalation across users: both secrets are already one person's, on one + * disk. It is the separation between personas, which is the whole of what a + * profile is. + * + * The key-state lookup is filtered rather than scoped, because it cannot be + * scoped: `DkgSession`'s primary key is the ceremony id the coordinator + * minted, which is the same string on every participant's device, so there is + * one row per ceremony per database and no second one to find. A state naming + * a ceremony that belongs to another profile therefore resolves to nothing + * here, which is the honest answer -- this profile has no share of it. + * + * That same single row is why two profiles *both* in one group is not merely + * scoped here but refused: `ChillDkgRitualManager.acceptProposal` returns the + * row the first of them wrote rather than writing a second, so the second + * profile holds no ceremony of its own to find and cannot sign, where before + * it would have signed with the first's share. Keying the row per profile is + * the fix for that, and it is a migration rather than a predicate. */ - suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? { + suspend fun completedKey( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey + ): DkgSession? { GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state -> - database.dkgSessionDao().getSessionById(state.dkgSessionId)?.takeIf { key -> - key.stage == DkgRitualStage.COMPLETE && - key.secretShare != null && - key.thresholdPublicKey == state.thresholdPublicKey - }?.let { return it } + database.dkgSessionDao() + .getSessionById(state.dkgSessionId, userPublicKey) + ?.takeIf { key -> + key.stage == DkgRitualStage.COMPLETE && + key.secretShare != null && + key.thresholdPublicKey == state.thresholdPublicKey + } + ?.let { return it } } - database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session -> + database.dkgSessionDao().getKeyHoldingSessions(userPublicKey).firstOrNull { session -> session.stage == DkgRitualStage.COMPLETE && session.thresholdPublicKey?.let { SharedKeyDerivation.marmotGroupId(it) == chatRoomId @@ -1355,13 +1406,22 @@ object FrostSigningManager { }?.let { return it } return database.dkgSessionDao() - .getLatestOwnSessionForChatRoom(chatRoomId) + .getLatestOwnSessionForChatRoom(chatRoomId, userPublicKey) ?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } } - /** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */ - suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean = - completedKey(database, chatRoomId) != null + /** + * Whether [userPublicKey] can sign for this group at all, read by the UI so it + * offers nothing that would fail. + * + * The profile is part of the question, not context around it. A group one + * profile holds a share of is a group the next profile is merely looking at. + */ + suspend fun canSign( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey + ): Boolean = completedKey(database, chatRoomId, userPublicKey) != null /** * The path off [key] that a signature made in this room is derived at, or diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index a10c237c..c6a1f72d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -103,12 +103,16 @@ object SubgroupManager { val admins = adminPublicKeys + coordinatorPublicKey val parentChatRoomId = parentRoom.chatRoom.id - // A subgroup is certified by the parent's key, so a device holding no + // A subgroup is certified by the parent's key, so a profile holding no // share of it cannot open the session at all -- // `FrostSigningManager.proposeSigningBatch` throws outright, and throwing // at a button is not a UI. Checked before the size rules because it is the // one refusal the user cannot fix by picking differently. - if (!FrostSigningManager.canSign(database, parentChatRoomId)) { + // + // Asked of the coordinator, who is the profile doing this. The device may + // hold another profile's share of the same group; that is not a share this + // one can sign the certificate with. + if (!FrostSigningManager.canSign(database, parentChatRoomId, coordinatorPublicKey)) { return "Only a member who holds a share of this group's key can make a subgroup." } @@ -176,7 +180,11 @@ object SubgroupManager { val running = database.dkgSessionDao().getLatestSubgroupSessionFor( chatRoomId = parentChatRoomId, parentChatRoomId = parentChatRoomId, - participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty() + participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty(), + // The coordinator's own ceremonies. A ritual the other profile on this + // device is running is not one this person can answer for, so it is no + // reason to refuse them a ceremony of their own. + userPublicKey = coordinatorPublicKey ) if (running != null && running.stage != DkgRitualStage.FAILED && @@ -391,7 +399,9 @@ object SubgroupManager { // `proposeSigningBatch` throws outright, and throwing at a button is not // a UI. First, because it is the one refusal the user cannot fix by // picking differently. - if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) { + // Asked of the profile doing the adopting. The device may hold another + // profile's share of that group; it is not a share this one can certify with. + if (!FrostSigningManager.canSign(database, adoptingChatRoomId, userPublicKey)) { return "Only a member who holds a share of that group's key can adopt a group into it." } @@ -661,9 +671,9 @@ object SubgroupManager { val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage) checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" } - val key = FrostSigningManager.completedKey(database, childChatRoomId) + val key = FrostSigningManager.completedKey(database, childChatRoomId, userPublicKey) ?: throw IllegalStateException( - "This device holds no share of $childChatRoomId's key to propose with" + "This profile holds no share of $childChatRoomId's key to propose with" ) logger.i( @@ -719,9 +729,9 @@ object SubgroupManager { "$childChatRoomId does not name $parentChatRoomId as a parent" } - val key = FrostSigningManager.completedKey(database, childChatRoomId) + val key = FrostSigningManager.completedKey(database, childChatRoomId, userPublicKey) ?: throw IllegalStateException( - "This device holds no share of $childChatRoomId's key to propose with" + "This profile holds no share of $childChatRoomId's key to propose with" ) val remaining = current.minus(parentChatRoomId) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index a5d67444..5ef7f7ad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -185,7 +185,9 @@ fun MantraNavHost( } // Before the repositories: the nostr repository stamps every fetch request it queues - // with the active identity, and takes the flow at construction. + // with the active identity, the chat and signing repositories answer "can this group + // sign" about it, the dkg repository reads the rituals belonging to it, and all four + // take the flow at construction. val sovereignWalletViewModel: SovereignWalletViewModel = viewModel( factory = SovereignWalletViewModel.factory( phoenixGlobal = phoenixGlobal @@ -203,21 +205,24 @@ fun MantraNavHost( val databaseChatRepository = remember { DatabaseChatRepository( database = auxDatabaseManager.auxDatabase, - applicationIOScope + applicationIOScope, + activeIdentity = sovereignWalletViewModel.activeIdentity, ) } val databaseDkgRepository = remember { DatabaseDkgRepository( database = auxDatabaseManager.auxDatabase, - applicationIOScope + applicationIOScope, + activeIdentity = sovereignWalletViewModel.activeIdentity, ) } val databaseFrostSigningRepository = remember { DatabaseFrostSigningRepository( database = auxDatabaseManager.auxDatabase, - applicationIOScope + applicationIOScope, + activeIdentity = sovereignWalletViewModel.activeIdentity, ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt index 8ab8e9b4..f0a5a1c7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/DkgSessionDaoJvmTest.kt @@ -84,11 +84,12 @@ class DkgSessionDaoJvmTest { thresholdPublicKey: String? = null, secretShare: String? = null, stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS, + userPublicKey: String = user, ): DkgSession = DkgSession( id = id, chatRoomId = chatRoomId, coordinatorPublicKey = user, - userPublicKey = user, + userPublicKey = userPublicKey, threshold = 2, participantCount = 3, stage = stage, @@ -106,9 +107,11 @@ class DkgSessionDaoJvmTest { kind: Int = hostKeyKind, payload: String = "aa".repeat(32), createdAt: Instant = Instant.fromEpochSeconds(1_000), + userPublicKey: String = user, ) = db.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = sessionId, + userPublicKey = userPublicKey, participantPublicKey = participant, kind = kind, payload = payload, @@ -127,7 +130,7 @@ class DkgSessionDaoJvmTest { session("abandoned", createdAt = Instant.fromEpochSeconds(1_000)) session("live", createdAt = Instant.fromEpochSeconds(2_000)) - assertEquals("live", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne)?.id) + assertEquals("live", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne, user)?.id) } @Test @@ -136,8 +139,8 @@ class DkgSessionDaoJvmTest { session("theirs", chatRoomId = roomTwo, createdAt = Instant.fromEpochSeconds(2_000)) session("mine", chatRoomId = roomOne, createdAt = Instant.fromEpochSeconds(1_000)) - assertEquals("mine", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne)?.id) - assertNull(db.dkgSessionDao().getLatestSessionForChatRoom("33".repeat(32))) + assertEquals("mine", db.dkgSessionDao().getLatestSessionForChatRoom(roomOne, user)?.id) + assertNull(db.dkgSessionDao().getLatestSessionForChatRoom("33".repeat(32), user)) } /** @@ -153,7 +156,7 @@ class DkgSessionDaoJvmTest { session("shareOnly", thresholdPublicKey = null, secretShare = "ee".repeat(32)) session("neither") - val holding = db.dkgSessionDao().getKeyHoldingSessions().map { it.id } + val holding = db.dkgSessionDao().getKeyHoldingSessions(user).map { it.id } assertEquals(listOf("complete"), holding) } @@ -165,7 +168,55 @@ class DkgSessionDaoJvmTest { session("older", createdAt = Instant.fromEpochSeconds(1_000), thresholdPublicKey = "dd".repeat(32), secretShare = "ee".repeat(32)) session("newer", createdAt = Instant.fromEpochSeconds(2_000), thresholdPublicKey = "dd".repeat(32), secretShare = "ee".repeat(32)) - assertEquals(listOf("newer", "older"), db.dkgSessionDao().getKeyHoldingSessions().map { it.id }) + assertEquals(listOf("newer", "older"), db.dkgSessionDao().getKeyHoldingSessions(user).map { it.id }) + } + + /** + * The isolation between two profiles on one device. `ChatRoom` is keyed by the group id + * alone, so when both profiles are in a group their ceremonies of it sit in one table with + * nothing but this column to tell them apart -- and a share is only usable by the profile + * whose ceremony derived it. + */ + @Test + fun `a profile holds only its own ceremonies key`() = runBlocking { + seedRooms() + session( + "mine", + thresholdPublicKey = "dd".repeat(32), + secretShare = "ee".repeat(32), + userPublicKey = user, + ) + session( + "the other profiles", + thresholdPublicKey = "dd".repeat(32), + secretShare = "ff".repeat(32), + userPublicKey = alice, + ) + + assertEquals(listOf("mine"), db.dkgSessionDao().getKeyHoldingSessions(user).map { it.id }) + assertEquals( + listOf("the other profiles"), + db.dkgSessionDao().getKeyHoldingSessions(alice).map { it.id }, + ) + assertEquals(emptyList(), db.dkgSessionDao().getKeyHoldingSessions(bob).map { it.id }) + } + + /** The same scoping on the room-shaped read `completedKey` falls back to. */ + @Test + fun `a rooms own ceremony is the asking profiles own`() = runBlocking { + seedRooms() + session("mine", stage = DkgRitualStage.COMPLETE, userPublicKey = user) + session("the other profiles", stage = DkgRitualStage.COMPLETE, userPublicKey = alice) + + assertEquals( + "mine", + db.dkgSessionDao().getLatestOwnSessionForChatRoom(roomOne, user)?.id, + ) + assertEquals( + "the other profiles", + db.dkgSessionDao().getLatestOwnSessionForChatRoom(roomOne, alice)?.id, + ) + assertNull(db.dkgSessionDao().getLatestOwnSessionForChatRoom(roomOne, bob)) } /** @@ -182,10 +233,10 @@ class DkgSessionDaoJvmTest { message("s1", alice, payload = "bb".repeat(32)) - assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind)) + assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", user, hostKeyKind)) assertEquals( "bb".repeat(32), - db.dkgSessionDao().getMessage("s1", hostKeyKind, alice)?.payload, + db.dkgSessionDao().getMessage("s1", user, hostKeyKind, alice)?.payload, "the resent payload should have replaced the earlier one", ) } @@ -198,11 +249,11 @@ class DkgSessionDaoJvmTest { message("s1", alice, kind = hostKeyKind, payload = "aa".repeat(32)) message("s1", alice, kind = round1Kind, payload = "cc".repeat(32)) - assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind)) - assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", round1Kind)) + assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", user, hostKeyKind)) + assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", user, round1Kind)) assertEquals( "aa".repeat(32), - db.dkgSessionDao().getMessage("s1", hostKeyKind, alice)?.payload, + db.dkgSessionDao().getMessage("s1", user, hostKeyKind, alice)?.payload, "the round-1 message overwrote the host key message", ) } @@ -221,7 +272,7 @@ class DkgSessionDaoJvmTest { message("s1", ordered[0], createdAt = Instant.fromEpochSeconds(2_000)) message("s1", ordered[1], createdAt = Instant.fromEpochSeconds(3_000)) - val found = db.dkgSessionDao().getMessagesByKind("s1", hostKeyKind).map { it.participantPublicKey } + val found = db.dkgSessionDao().getMessagesByKind("s1", user, hostKeyKind).map { it.participantPublicKey } assertEquals(ordered, found, "a round must assemble in canonical participant order") } @@ -237,9 +288,9 @@ class DkgSessionDaoJvmTest { message("s1", alice, kind = round1Kind) message("s2", alice, kind = hostKeyKind) - assertEquals(2, db.dkgSessionDao().countMessagesByKind("s1", hostKeyKind)) - assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", round1Kind)) - assertEquals(1, db.dkgSessionDao().countMessagesByKind("s2", hostKeyKind)) - assertNull(db.dkgSessionDao().getMessage("s2", round1Kind, alice)) + assertEquals(2, db.dkgSessionDao().countMessagesByKind("s1", user, hostKeyKind)) + assertEquals(1, db.dkgSessionDao().countMessagesByKind("s1", user, round1Kind)) + assertEquals(1, db.dkgSessionDao().countMessagesByKind("s2", user, hostKeyKind)) + assertNull(db.dkgSessionDao().getMessage("s2", user, round1Kind, alice)) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt index 1e1a9e9f..18cd114e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt @@ -310,9 +310,9 @@ class SubgroupDaoJvmTest { // is no way back at all. assertEquals( listOf("s1"), - db.dkgSessionDao().getByParentChatRoomId(parentRoom).map { it.id } + db.dkgSessionDao().getByParentChatRoomId(parentRoom, user).map { it.id } ) - assertNull(db.dkgSessionDao().getSessionById("s2")?.parentChatRoomId) + assertNull(db.dkgSessionDao().getSessionById("s2", user)?.parentChatRoomId) } private suspend fun seedRooms() { diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt new file mode 100644 index 00000000..a09c2f33 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/DkgSessionPerProfileMigrationJvmTest.kt @@ -0,0 +1,286 @@ +package press.mantra.compose.database.migrations + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The v21 -> v22 rebuild, against a real database holding a real finished ceremony. + * + * The stake is the share. A `DkgSession` row holds `secretShare` and the two pieces + * of randomness that reconstruct it, and this migration drops and recreates the + * table that holds them -- with a child table pointing at it `ON DELETE CASCADE`. + * Get the order wrong and the upgrade silently deletes a group's ability to sign: + * the messages first, by cascade, and with them every ritual still in flight. + * + * So these assert the values through, not merely that rows survived. Foreign keys + * are turned **on** for the whole test, which is the hostile setting -- it is the + * one under which a mis-ordered drop would cascade -- so passing here means the + * migration does not depend on Room having turned them off. + * + * Run against the migration's own SQL on a bare connection, as + * [FrostSigningItemMigrationJvmTest] is and for the same reason: what is under test + * is the copy and the shape left behind, not Room's version wiring. + */ +class DkgSessionPerProfileMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + private val alice = "0a".repeat(32) + private val bob = "0b".repeat(32) + private val secretShare = "cc".repeat(32) + private val round1Random = "aa".repeat(32) + private val round2AuxRandom = "bb".repeat(32) + private val hostKeyPayload = "01".repeat(33) + + /** + * The two ritual tables and the room they hang off, verbatim from `schemas/21.json` + * -- neither of them changed between 20 and 21, which added the parentage table. + */ + private fun createV21() { + connection.execSQL("PRAGMA foreign_keys = ON") + + // Only the columns the foreign key needs; this is not the room table under test. + connection.execSQL("CREATE TABLE IF NOT EXISTS `ChatRoom` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))") + + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `DkgSession` (`id` TEXT NOT NULL, " + + "`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " + + "`userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, " + + "`participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, " + + "`subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, " + + "`round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, " + + "`coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, " + + "`secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, " + + "`failureReason` TEXT, `hostKeyApprovedAt` INTEGER, " + + "`round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, " + + "`approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, " + + "`createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " + + "`savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` " + + "ON `DkgSession` (`chatRoomId`)" + ) + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `DkgParticipantMessage` (`sessionId` TEXT NOT NULL, " + + "`participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, " + + "`payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " + + "PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), " + + "FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` " + + "ON `DkgParticipantMessage` (`sessionId`)" + ) + + connection.execSQL("INSERT INTO `ChatRoom` VALUES ('room')") + } + + /** A ceremony that finished, so the row holds a share worth not losing. */ + private fun insertV21Session(id: String = "ceremony-1", owner: String = alice) = + connection.execSQL( + "INSERT INTO `DkgSession` VALUES (" + + "'$id', 'room', '$owner', '$owner', 2, 3, '$alice,$bob', 'Ekklesia', " + + "'COMPLETE', '$hostKeyPayload', '$round1Random', '$round2AuxRandom', " + + "NULL, NULL, '02${"ab".repeat(32)}', '$secretShare', NULL, NULL, NULL, " + + "1000, 1000, 1000, NULL, NULL, 1000, 1000, 1000)" + ) + + private fun insertV21Message( + sessionId: String = "ceremony-1", + participant: String = bob, + kind: Int = 30450, + ) = connection.execSQL( + "INSERT INTO `DkgParticipantMessage` VALUES " + + "('$sessionId', '$participant', $kind, '$hostKeyPayload', 1000)" + ) + + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + /** The primary-key columns of [table], in key order. */ + private fun primaryKey(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { + while (statement.step()) { + // columns: cid, name, type, notnull, dflt_value, pk + val position = statement.getInt(5) + if (position > 0) add(position to statement.getText(1)) + } + } + }.sortedBy { it.first }.map { it.second } + + private fun query(sql: String, read: (androidx.sqlite.SQLiteStatement) -> T): List = + connection.prepare(sql).use { statement -> + buildList { while (statement.step()) add(read(statement)) } + } + + private fun count(table: String): Int = + query("SELECT COUNT(*) FROM `$table`") { it.getInt(0) }.first() + + @Test + fun `the key becomes the ceremony and the profile`() = runBlocking { + createV21() + insertV21Session() + + MIGRATION_21_22.migrate(connection) + + assertEquals(listOf("id", "userPublicKey"), primaryKey("DkgSession")) + assertEquals( + listOf("sessionId", "userPublicKey", "participantPublicKey", "kind"), + primaryKey("DkgParticipantMessage"), + ) + + // The column order Room checks the table against, unchanged apart from the + // one insertion. + assertEquals( + listOf( + "id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "threshold", + "participantCount", "participantPublicKeys", "subject", "stage", + "hostPublicKey", "round1Random", "round2AuxRandom", "coordinatorRound1", + "certificate", "thresholdPublicKey", "secretShare", "publicShares", + "recoveryData", "failureReason", "hostKeyApprovedAt", "round1ApprovedAt", + "round2ApprovedAt", "approvalRequestedThrough", "parentChatRoomId", + "createdAt", "updatedAt", "savedAt", + ), + columns("DkgSession"), + ) + assertEquals( + listOf("sessionId", "userPublicKey", "participantPublicKey", "kind", "payload", "createdAt"), + columns("DkgParticipantMessage"), + ) + } + + /** + * The one that matters. `DkgParticipantMessage` cascades off `DkgSession`, so a + * migration that dropped the parent while the child still pointed at it would + * delete the ritual's messages -- and with foreign keys on, as they are here, + * it would do it silently. + */ + @Test + fun `a finished ceremony keeps its share and its messages`() = runBlocking { + createV21() + insertV21Session() + insertV21Message(participant = bob) + insertV21Message(participant = alice) + + MIGRATION_21_22.migrate(connection) + + assertEquals(1, count("DkgSession")) + assertEquals(2, count("DkgParticipantMessage"), "the cascade must not have fired") + + val session = query( + "SELECT `userPublicKey`, `secretShare`, `round1Random`, `round2AuxRandom`, `stage` " + + "FROM `DkgSession`" + ) { listOf(it.getText(0), it.getText(1), it.getText(2), it.getText(3), it.getText(4)) }.single() + + assertEquals( + listOf(alice, secretShare, round1Random, round2AuxRandom, "COMPLETE"), + session, + "every secret the share is rebuilt from has to come through verbatim", + ) + + // Each message is now owned by the profile whose session it hung off. + assertEquals( + listOf(alice to alice, alice to bob), + query( + "SELECT `userPublicKey`, `participantPublicKey` FROM `DkgParticipantMessage` " + + "ORDER BY `participantPublicKey`" + ) { it.getText(0) to it.getText(1) }, + ) + } + + /** Two ceremonies, so the backfill is shown picking per message rather than one owner. */ + @Test + fun `each message takes the owner of its own ceremony`() = runBlocking { + createV21() + insertV21Session(id = "alices", owner = alice) + insertV21Session(id = "bobs", owner = bob) + insertV21Message(sessionId = "alices", participant = bob) + insertV21Message(sessionId = "bobs", participant = alice) + + MIGRATION_21_22.migrate(connection) + + assertEquals( + listOf("alices" to alice, "bobs" to bob), + query( + "SELECT `sessionId`, `userPublicKey` FROM `DkgParticipantMessage` ORDER BY `sessionId`" + ) { it.getText(0) to it.getText(1) }, + ) + } + + /** + * The cascade still works afterwards, which is the half of this a rebuild can + * quietly lose: a child recreated without its foreign key would leave a deleted + * room's rituals behind, and nothing in the app would ever notice. + */ + @Test + fun `deleting the room still takes the ritual and its messages`() = runBlocking { + createV21() + insertV21Session() + insertV21Message() + + MIGRATION_21_22.migrate(connection) + connection.execSQL("DELETE FROM `ChatRoom` WHERE `id` = 'room'") + + assertEquals(0, count("DkgSession")) + assertEquals(0, count("DkgParticipantMessage")) + } + + /** + * A message whose session is gone has no owner to take, and could not satisfy + * the new key either. The old foreign key should have made it impossible; if one + * exists it was written with foreign keys off, and keeping it would mean + * inventing an owner for somebody else's secret. + */ + @Test + fun `a message with no ceremony is dropped rather than given an owner`() = runBlocking { + createV21() + insertV21Session() + insertV21Message(participant = bob) + + // Off, so the orphan can be created at all -- which is the only way it could + // have got there in the first place. + connection.execSQL("PRAGMA foreign_keys = OFF") + insertV21Message(sessionId = "no-such-ceremony", participant = alice) + connection.execSQL("PRAGMA foreign_keys = ON") + + MIGRATION_21_22.migrate(connection) + + assertEquals(1, count("DkgParticipantMessage")) + assertTrue( + query("SELECT `sessionId` FROM `DkgParticipantMessage`") { it.getText(0) } + .none { it == "no-such-ceremony" }, + ) + } + + /** Nothing of the migration's own scaffolding is left behind. */ + @Test + fun `the backup tables are gone`() = runBlocking { + createV21() + insertV21Session() + + MIGRATION_21_22.migrate(connection) + + assertEquals( + emptyList(), + query( + "SELECT `name` FROM `sqlite_master` WHERE `type` = 'table' AND `name` LIKE '%Backup'" + ) { it.getText(0) }, + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt index 31c3687a..aa37938d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt @@ -126,6 +126,7 @@ class ChapterBatchProposalJvmTest { db.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = ceremonyId, + userPublicKey = proposer, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = hostKeys[index] diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ProfileScopedSigningKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ProfileScopedSigningKeyJvmTest.kt new file mode 100644 index 00000000..57752b78 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ProfileScopedSigningKeyJvmTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.managers + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import java.nio.file.Files +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import okio.Path.Companion.toPath +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId + +/** + * One device, two profiles, one database -- and only one of them holds the share. + * + * Each profile has its own groups and its own ceremonies, and every one of those + * rows lands in this one table -- `ChatRoom` is keyed by the group id alone, so + * even the rooms are shared, and the only thing separating one profile's ceremony + * from the other's is `DkgSession.userPublicKey`. Every one of `completedKey`'s + * three answers used to ignore that column, so the question "can this group sign" + * was answered about the *device* and read by the UI as being about the user in + * front of it. + * + * What that costs is not a secret: both shares are already one person's, on one + * disk, and the share handed over is not one the asking profile can make a valid + * signature with -- `signerIdOf` throws, because that profile is no participant of + * the ceremony it was given. What it costs is the separation between two personas, + * which is the whole of what a profile is, plus a button that fails after having + * been offered. + * + * Three tests, one per answer, because each resolves a key by a different route + * and each had to be scoped separately. The fourth is the seam the UI reads. + */ +class ProfileScopedSigningKeyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val scope = CoroutineScope(Dispatchers.IO) + + private val aliceKey = PrivateKey(ByteVector32("0a".repeat(32))) + private val bobKey = PrivateKey(ByteVector32("0b".repeat(32))) + + /** The two profiles signed in on this device. */ + private val alice: HexKey = aliceKey.nostrPublicKeyHex() + private val bob: HexKey = bobKey.nostrPublicKeyHex() + + /** The room the group's ceremony ran in: a NIP-17 room, derived from nobody. */ + private val ceremonyRoomId = "11".repeat(32) + + /** A group only Bob is in, so neither profile is tested against an empty table. */ + private val bobsRoomId = "22".repeat(32) + + /** + * Alice's group's key. Dealt rather than run: what is under test is which row a + * lookup returns, not whether ChillDKG agrees on a key. + */ + private val aliceMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("a11ce00000000000000000000000000000000000000000000000000000000001") + ), + nParticipants = 3, + threshold = 2 + ) + + private val bobMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("b0b0000000000000000000000000000000000000000000000000000000000002") + ), + nParticipants = 3, + threshold = 2 + ) + + private val aliceThresholdKey: HexKey get() = aliceMaterial.thresholdPublicKey.value.toHex() + private val bobThresholdKey: HexKey get() = bobMaterial.thresholdPublicKey.value.toHex() + + /** Alice's group's admin room: its id *is* her group's key at the app's path. */ + private val aliceAdminRoomId: String get() = SharedKeyDerivation.marmotGroupId(aliceThresholdKey) + + /** + * Alice's ceremony, as her profile holds it, and Bob's of his own group. + * + * Both complete and both holding a share, which is what makes them findable at + * all. Alice's is filed under the room it ran in and claims no parent, so it is + * her group's own key by every route `completedKey` has. + */ + private suspend fun seedTwoProfiles() { + seedProfile(alice) + seedProfile(bob) + + // One row per room, as the schema has it -- the second profile does not get + // a room of its own, which is exactly why the ceremony has to carry the + // distinction. + seedRoom(ceremonyRoomId, userPublicKey = alice) + seedRoom(bobsRoomId, userPublicKey = bob) + + seedCeremony( + id = "alices-ceremony", + chatRoomId = ceremonyRoomId, + userPublicKey = alice, + thresholdPublicKey = aliceThresholdKey, + ) + seedCeremony( + id = "bobs-ceremony", + chatRoomId = bobsRoomId, + userPublicKey = bob, + thresholdPublicKey = bobThresholdKey, + ) + } + + /** + * The scan over every ceremony a share is held for, which is how a real admin + * room resolves its key: rederive each one's room id and match. + */ + @Test + fun `the rederivation scan reaches only the asking profiles ceremony`(): Unit = runBlocking { + seedTwoProfiles() + + assertEquals( + "alices-ceremony", + FrostSigningManager.completedKey(db, aliceAdminRoomId, alice)?.id, + "the profile that ran the ceremony signs with it", + ) + assertTrue(FrostSigningManager.canSign(db, aliceAdminRoomId, alice)) + + assertNull( + FrostSigningManager.completedKey(db, aliceAdminRoomId, bob), + "the other profile on this device holds no share of Alice's group", + ) + assertFalse( + FrostSigningManager.canSign(db, aliceAdminRoomId, bob), + "and must not be offered an action that would throw at the button", + ) + + // Not a database that answers nothing: Bob resolves his own group, in the + // same scan, from the same table. + assertEquals( + "bobs-ceremony", + FrostSigningManager.completedKey( + db, + SharedKeyDerivation.marmotGroupId(bobThresholdKey), + bob + )?.id, + ) + } + + /** + * The last fallback: a ceremony held in this very room. It is what every member + * welcomed after the group's own ceremony reaches, holding no key state. + * + * Reached here because the room asked about is the NIP-17 room the ceremony ran + * in, which is an aggregate of its members' keys and derived from no threshold + * key at all -- so the scan above cannot match it. + */ + @Test + fun `a rooms own ceremony belongs to one profile`(): Unit = runBlocking { + seedTwoProfiles() + + assertEquals( + "alices-ceremony", + FrostSigningManager.completedKey(db, ceremonyRoomId, alice)?.id, + ) + assertNull( + FrostSigningManager.completedKey(db, ceremonyRoomId, bob), + "a room's own ceremony is the asking profile's own or it is nothing", + ) + } + + /** + * The lookup in front of both fallbacks: the room's `GroupKeyState`, naming the + * ceremony behind it. + * + * Filtered rather than scoped, because it cannot be scoped -- `DkgSession`'s + * primary key is the ceremony id the coordinator minted, the same string on + * every participant's device, so there is one row per ceremony and no second + * one to find. A profile that does not own that row resolves nothing, which is + * the honest answer: it has no share of it. + * + * The room here is derived at a path other than the app's default, so the + * rederivation scan cannot reach the ceremony and the key state is the only + * thing that can answer -- which is the case the state was added for. + */ + @Test + fun `a key state naming another profiles ceremony resolves nothing`(): Unit = runBlocking { + seedTwoProfiles() + + val path = listOf(9420L, 0L, 1L) + val roomId = SharedKeyDerivation.marmotGroupId(aliceThresholdKey, path) + seedRoom(roomId, userPublicKey = alice) + + db.groupKeyStateDao().upsert( + GroupKeyState( + chatRoomId = roomId, + dkgSessionId = "alices-ceremony", + thresholdPublicKey = aliceThresholdKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = alice, + announcedAt = Instant.fromEpochSeconds(1_000), + ) + ) + + assertEquals( + "alices-ceremony", + FrostSigningManager.completedKey(db, roomId, alice)?.id, + "the key state is the only route to a room derived off the app's path", + ) + assertNull( + FrostSigningManager.completedKey(db, roomId, bob), + "a state Bob's profile holds no share for is no key of his", + ) + } + + /** + * The seam the UI reads. Every screen that offers a signing action asks the + * repository, which stamps the question with the profile that is open rather + * than taking one from the call site -- a screen asking "can this group sign" + * means itself and cannot be made to mean anyone else. + */ + @Test + fun `the repository answers for the profile that is open`(): Unit = runBlocking { + seedTwoProfiles() + + val active = MutableStateFlow(null) + val repository = DatabaseFrostSigningRepository(db, scope, activeIdentity = active) + + assertFalse( + repository.canSign(aliceAdminRoomId), + "nobody open signs nothing, whatever the device holds", + ) + + active.value = identity(aliceKey) + assertTrue(repository.canSign(aliceAdminRoomId)) + + active.value = identity(bobKey) + assertFalse( + repository.canSign(aliceAdminRoomId), + "switching profile switches the answer, without the room changing", + ) + assertTrue( + repository.canSign(SharedKeyDerivation.marmotGroupId(bobThresholdKey)), + "and Bob can still sign for his own group", + ) + } + + private suspend fun seedCeremony( + id: String, + chatRoomId: String, + userPublicKey: HexKey, + thresholdPublicKey: HexKey, + ) = db.dkgSessionDao().upsert( + DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = userPublicKey, + userPublicKey = userPublicKey, + threshold = 2, + participantCount = 3, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = userPublicKey, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + thresholdPublicKey = thresholdPublicKey, + secretShare = "cc".repeat(32), + ) + ) + + private suspend fun seedRoom(id: String, userPublicKey: HexKey) = + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = userPublicKey, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ) + ) + + private suspend fun seedProfile(publicKey: HexKey) { + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "profile", nostrEventId = nostrEventId) + ) + } + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt index a2863aa8..bc2bc7ff 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -332,7 +332,7 @@ class RobustRoomKeyCeremonyTest { // What the screen asks when somebody arrives from the transcript, with // nothing but the room. - val found = db.dkgSessionDao().observeLatestSessionForChatRoom(room.chatRoom.id).first() + val found = db.dkgSessionDao().observeLatestSessionForChatRoom(room.chatRoom.id, user).first() assertEquals(session.id, found?.id, "a member arriving by room has to find the ceremony") assertEquals( @@ -357,11 +357,11 @@ class RobustRoomKeyCeremonyTest { assertEquals( own.id, - db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, null)?.id, + db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, null, user)?.id, ) assertEquals( subgroup.id, - db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, parent)?.id, + db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, parent, user)?.id, ) // And asking again for the same purpose hands back what is already @@ -416,7 +416,7 @@ class RobustRoomKeyCeremonyTest { lines.filter { it.dkgSessionId == subgroup.id }.forEach { assertEquals( subgroup.id, - db.dkgSessionDao().observeSessionById(it.dkgSessionId!!).first()?.id, + db.dkgSessionDao().observeSessionById(it.dkgSessionId!!, user).first()?.id, "tapping a subgroup line reaches the subgroup's ceremony", ) } @@ -450,8 +450,8 @@ class RobustRoomKeyCeremonyTest { // ceremony is being shown. assertEquals( 2, - db.dkgSessionDao().getByParentChatRoomId("ab".repeat(32)).size + - (if (db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, null) != null) 1 else 0), + db.dkgSessionDao().getByParentChatRoomId("ab".repeat(32), user).size + + (if (db.dkgSessionDao().getLatestSessionFor(room.chatRoom.id, null, user) != null) 1 else 0), "a room can end up holding two ceremonies, so the UI must not offer a third", ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 90bd88d6..756be58f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -284,6 +284,7 @@ class SignedGroupKeyStateTest { db.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = ceremonyId, + userPublicKey = publicKey, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = hostKeys[index] @@ -300,7 +301,7 @@ class SignedGroupKeyStateTest { } private suspend fun ceremonyOn(device: Device): DkgSession = - device.db.dkgSessionDao().getSessionById(ceremonyId)!! + device.db.dkgSessionDao().getSessionById(ceremonyId, device.publicKey)!! /** * One queued protocol message, whichever store it came out of. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt index c30df4c8..44b247ed 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt @@ -252,7 +252,7 @@ class SubgroupCeremonyInParentRoomTest { ) assertNull( - receiver.dkgSessionDao().getSessionById(session.id), + receiver.dkgSessionDao().getSessionById(session.id, malloryPublicKey), "a member outside the p-tags opens no session", ) assertTrue( @@ -287,7 +287,7 @@ class SubgroupCeremonyInParentRoomTest { ) val joined = assertNotNull( - receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId), + receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId, alice), "a picked admin records the ceremony", ) assertEquals(3, joined.participantCount) @@ -306,6 +306,7 @@ class SubgroupCeremonyInParentRoomTest { database = receiver, localChatRoom = theirRoom, sessionId = joined.id, + userPublicKey = alice, step = DkgApprovalStep.HOST_KEY, nostrPrivateKey = aliceKeys.privKey!! ) @@ -364,7 +365,7 @@ class SubgroupCeremonyInParentRoomTest { ) assertNotNull( - receiver.dkgSessionDao().getMessage(session.id, DkgRitualEvents.HOST_KEY, bob), + receiver.dkgSessionDao().getMessage(session.id, alice, DkgRitualEvents.HOST_KEY, bob), "the message that lost the race is not lost", ) } @@ -414,7 +415,7 @@ class SubgroupCeremonyInParentRoomTest { ) assertNull( - FrostSigningManager.completedKey(parent, parentRoomId), + FrostSigningManager.completedKey(parent, parentRoomId, user), "a ceremony run to make a subgroup is never the room's own key", ) @@ -426,7 +427,8 @@ class SubgroupCeremonyInParentRoomTest { parent, SharedKeyDerivation.marmotGroupId( childMaterial.thresholdPublicKey.value.toHex() - ) + ), + user )?.id, ) } @@ -494,6 +496,7 @@ class SubgroupCeremonyInParentRoomTest { parent.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = "parent-ceremony", + userPublicKey = user, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = "0${index + 1}".repeat(33) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index 32ee1bd3..6ed77df2 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -1002,6 +1002,7 @@ class SubgroupManagerJvmTest { db.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = ceremonyId, + userPublicKey = user, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = "0${index + 1}".repeat(33) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt index 90f6f4cc..3a1fd56a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TranslationBatchProposalJvmTest.kt @@ -133,6 +133,7 @@ class TranslationBatchProposalJvmTest { db.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = ceremonyId, + userPublicKey = proposer, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = hostKeys[index] diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesInOneCeremonyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesInOneCeremonyJvmTest.kt new file mode 100644 index 00000000..d58a76f3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/TwoProfilesInOneCeremonyJvmTest.kt @@ -0,0 +1,406 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * One device, two profiles, and a group both of them are in. + * + * The ceremony id is minted by its coordinator and travels on every message, so it + * is one string for every participant -- and two profiles on a device are two + * participants. While a `DkgSession` was keyed by that id alone there was one row + * for both of them: `acceptProposal` opened with "do I know this ceremony?", found + * the row the first profile wrote, and returned it. The second profile then ran the + * ritual out of the first's row -- the first's host key, the first's randomness, + * and in the end the first's share -- while the protocol had them as two members + * with two of each. + * + * Nothing here is about one profile reading the other's secrets, which it could + * already do by being the same person on the same disk. It is about the ritual + * being able to count. A ceremony derives each participant's FROST id from the + * sorted host keys and hashes `n` into its own identity, so a device that + * contributes one host key where the group expects two is a device whose shares + * will not aggregate -- and the group finds that out at its first signature. + * + * Driven through the manager rather than seeded, because what is under test is the + * write path: `proposeRitual` for the profile that opens it, `processRitualPayload` + * for the one that receives the proposal, and `approve` for each. + */ +class TwoProfilesInOneCeremonyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + // Real keys: a host key is a hash of a nostr secret and the participant order is + // the sorted host keys, so filler exercises neither. + private val aliceKeys = KeyPair() + private val bobKeys = KeyPair() + private val carolKeys = KeyPair() + + /** The two profiles signed in on this one device. */ + private val alice = aliceKeys.pubKey.toHexKey() + private val bob = bobKeys.pubKey.toHexKey() + + /** A third member, on somebody else's device, so the group is not just this one. */ + private val carol = carolKeys.pubKey.toHexKey() + + private val quorum = 2 + + private suspend fun seedProfile(publicKey: String) { + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } + + /** The group's NIP-17 room, as the creation screen leaves it. */ + private suspend fun room(): LocalChatRoom { + listOf(alice, bob, carol).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = alice, + participantPublicKeys = listOf(bob, carol), + subject = "Two personas, one group", + ), + "createNip17ChatRoom returned null", + ) + } + + /** Alice opens it, which is also her agreeing to be in it. */ + private suspend fun openAsAlice(room: LocalChatRoom): DkgSession = + ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = room, + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!!, + threshold = quorum, + ) + + /** + * A queued payload as the rumor its sender wrote it. + * + * `sig` is empty, as it is on both transports: what vouches for the author is the + * seal, not a signature on the payload. + */ + private fun GiftWrapPayload.asRumor(): Event = Event( + id = id, + pubKey = publicKey, + createdAt = createdAt.epochSeconds, + kind = kind, + tags = tags, + content = content, + sig = "", + ) + + /** What one profile put on the wire of [kind], as the other would receive it. */ + private suspend fun queued(room: LocalChatRoom, kind: Int): List = + db.giftWrapPayloadDao() + .getByChatRoomAndKinds(room.chatRoom.id, listOf(kind)) + .map { it.asRumor() } + + /** The proposal as it went on the wire, which is what Bob's profile receives. */ + private suspend fun proposalIn(room: LocalChatRoom): Event = assertNotNull( + queued(room, DkgRitualEvents.PROPOSAL).firstOrNull(), + "the ceremony has to put a proposal on the wire", + ) + + /** Bob's profile receives it exactly as a second device would. */ + private suspend fun receiveAsBob(room: LocalChatRoom) = + ChillDkgRitualManager.processRitualPayload( + database = db, + localChatRoom = room, + innerEvent = proposalIn(room), + userPublicKey = bob, + nostrPrivateKey = bobKeys.privKey!!, + ) + + @Test + fun `each profile opens a ritual row of its own`(): Unit = runBlocking { + val room = room() + val opened = openAsAlice(room) + receiveAsBob(room) + + val alices = assertNotNull( + db.dkgSessionDao().getSessionById(opened.id, alice), + "the profile that opened the ceremony has its row", + ) + val bobs = assertNotNull( + db.dkgSessionDao().getSessionById(opened.id, bob), + "and so does the profile that received the proposal", + ) + + assertEquals(opened.id, alices.id) + assertEquals(opened.id, bobs.id, "one ceremony, named the same on both rows") + assertEquals(alice, alices.userPublicKey) + assertEquals(bob, bobs.userPublicKey) + + // Alice coordinates it either way, which is a fact about the ceremony rather + // than about whose row it is. + assertEquals(alice, alices.coordinatorPublicKey) + assertEquals(alice, bobs.coordinatorPublicKey) + } + + /** + * The sharp end. Each row has to hold its own profile's secrets -- the host key + * the group identifies that participant by, and the randomness its share is + * rebuilt from. Sharing a row meant sharing all three. + */ + @Test + fun `the two rows hold different host keys and different randomness`(): Unit = runBlocking { + val room = room() + val opened = openAsAlice(room) + receiveAsBob(room) + + val alices = assertNotNull(db.dkgSessionDao().getSessionById(opened.id, alice)) + val bobs = assertNotNull(db.dkgSessionDao().getSessionById(opened.id, bob)) + + assertEquals( + ChillDkgRitualManager.deriveHostPublicKey(aliceKeys.privKey!!).toString(), + alices.hostPublicKey, + "a profile's host key is derived from its own nostr secret", + ) + assertEquals( + ChillDkgRitualManager.deriveHostPublicKey(bobKeys.privKey!!).toString(), + bobs.hostPublicKey, + ) + assertNotEquals( + alices.hostPublicKey, + bobs.hostPublicKey, + "two participants the group must be able to tell apart", + ) + + assertNotEquals( + alices.round1Random, + bobs.round1Random, + "the seed a share is rebuilt from is not shared between personas", + ) + assertNotEquals(alices.round2AuxRandom, bobs.round2AuxRandom) + } + + /** + * Approval is per profile, and so is what it publishes. + * + * Opening the ceremony is Alice agreeing to be in it, so her host key is already + * out; Bob's profile has agreed to nothing and must have published nothing. That + * gate is the one thing standing between a relay delivering an event to a phone + * in a pocket and its owner being enrolled in a permanent signing quorum, and it + * has to hold per persona rather than per device. + */ + @Test + fun `one profile's approval publishes only that profile's host key`(): Unit = runBlocking { + val room = room() + val opened = openAsAlice(room) + receiveAsBob(room) + + assertNotNull( + db.dkgSessionDao().getMessage(opened.id, alice, DkgRitualEvents.HOST_KEY, alice), + "opening the ceremony published the opener's host key", + ) + assertNull( + db.dkgSessionDao().getSessionById(opened.id, bob)?.hostKeyApprovedAt, + "the receiving profile has agreed to nothing yet", + ) + assertNull( + db.dkgSessionDao().getMessage(opened.id, bob, DkgRitualEvents.HOST_KEY, bob), + "so nothing of theirs has gone out", + ) + + ChillDkgRitualManager.approve( + database = db, + localChatRoom = room, + sessionId = opened.id, + userPublicKey = bob, + step = DkgApprovalStep.HOST_KEY, + nostrPrivateKey = bobKeys.privKey!!, + ) + + val bobsOwn = assertNotNull( + db.dkgSessionDao().getMessage(opened.id, bob, DkgRitualEvents.HOST_KEY, bob), + "approving publishes this profile's host key", + ) + assertEquals( + ChillDkgRitualManager.deriveHostPublicKey(bobKeys.privKey!!).toString(), + bobsOwn.payload, + "and it is this profile's, not the other's", + ) + + // Alice's row is untouched by Bob approving: the approval reached one row. + assertNotNull(db.dkgSessionDao().getSessionById(opened.id, alice)?.hostKeyApprovedAt) + } + + /** + * What the ritual actually asks, and the reason the messages are keyed by the + * receiving profile as well: "have I heard from everybody" is a question about + * one profile's view, and a count over the device's rows would answer it with + * somebody else's mail. + * + * The two views fill separately. Joining replays the room's stored backlog -- a + * ritual whose proposal loses the race to its own round-1 messages would + * otherwise wait forever -- so Bob's profile starts with Alice's host key already + * in it, which is right: on a second device that message would have arrived too. + * What it does not start with is Bob's own, because Bob has agreed to nothing. + */ + @Test + fun `each profile counts the messages in its own view`(): Unit = runBlocking { + val room = room() + val opened = openAsAlice(room) + receiveAsBob(room) + + // Alice has published hers. Bob has published nothing and holds hers, off the + // replay, which is the backlog a real second device would have received. + assertEquals( + 1, + db.dkgSessionDao().countMessagesByKind(opened.id, alice, DkgRitualEvents.HOST_KEY), + ) + assertEquals( + 1, + db.dkgSessionDao().countMessagesByKind(opened.id, bob, DkgRitualEvents.HOST_KEY), + ) + assertNotNull( + db.dkgSessionDao().getMessage(opened.id, bob, DkgRitualEvents.HOST_KEY, alice), + "and the one it holds is Alice's", + ) + assertNull( + db.dkgSessionDao().getMessage(opened.id, bob, DkgRitualEvents.HOST_KEY, bob), + "not its own, which it has not agreed to publish", + ) + + ChillDkgRitualManager.approve( + database = db, + localChatRoom = room, + sessionId = opened.id, + userPublicKey = bob, + step = DkgApprovalStep.HOST_KEY, + nostrPrivateKey = bobKeys.privKey!!, + ) + + assertEquals( + 2, + db.dkgSessionDao().countMessagesByKind(opened.id, bob, DkgRitualEvents.HOST_KEY), + "Bob now holds Alice's and its own", + ) + assertEquals( + 1, + db.dkgSessionDao().countMessagesByKind(opened.id, alice, DkgRitualEvents.HOST_KEY), + "and Alice is unmoved by it, not having received Bob's yet", + ) + + // The point of the key. Three message rows on this device, and no profile + // sees three: a count over the device's rows would have had both of them + // believing a three-member ceremony was fully subscribed. + assertEquals(3, allHostKeyRows(opened.id), "three rows, two views") + + // And neither profile has been walked onto a rung it has not earned: a + // three-member ceremony wants three host keys before anyone contributes to + // the key, and nobody has Carol's. + listOf(alice, bob).forEach { profile -> + val session = assertNotNull(db.dkgSessionDao().getSessionById(opened.id, profile)) + assertEquals(3, session.participantCount) + assertNull( + ChillDkgRitualManager.pendingApproval(db, session), + "waiting on Carol is waiting, not asking", + ) + } + } + + /** + * And a message from the *other* profile lands under the receiver, which is the + * case that used to collapse: both are participants, so each legitimately holds a + * host-key message from the other, filed under itself. + */ + @Test + fun `a message from the other profile lands under the receiver`(): Unit = runBlocking { + val room = room() + val opened = openAsAlice(room) + receiveAsBob(room) + + ChillDkgRitualManager.approve( + database = db, + localChatRoom = room, + sessionId = opened.id, + userPublicKey = bob, + step = DkgApprovalStep.HOST_KEY, + nostrPrivateKey = bobKeys.privKey!!, + ) + + // Bob's host key, as it went out, fed back to Alice's profile the way the + // other device's would arrive. + val bobsHostKey = assertNotNull( + queued(room, DkgRitualEvents.HOST_KEY).firstOrNull { it.pubKey == bob }, + "Bob's approval put a host key on the wire", + ) + + ChillDkgRitualManager.processRitualPayload( + database = db, + localChatRoom = room, + innerEvent = bobsHostKey, + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!!, + ) + + val received = assertNotNull( + db.dkgSessionDao().getMessage(opened.id, alice, DkgRitualEvents.HOST_KEY, bob), + "Alice's profile now holds Bob's host key", + ) + + // The two columns mean different things, and this is where that shows. + assertEquals(alice, received.userPublicKey, "whose view it is") + assertEquals(bob, received.participantPublicKey, "who sent it") + assertEquals( + ChillDkgRitualManager.deriveHostPublicKey(bobKeys.privKey!!).toString(), + received.payload, + "and the payload is the sender's host key, not the receiver's", + ) + + assertEquals( + 2, + db.dkgSessionDao().countMessagesByKind(opened.id, alice, DkgRitualEvents.HOST_KEY), + "two of three, so Alice is still waiting on Carol", + ) + assertEquals( + 2, + db.dkgSessionDao().countMessagesByKind(opened.id, bob, DkgRitualEvents.HOST_KEY), + "and Bob still holds exactly the two it had", + ) + } + + /** Every host-key row on the device, whichever profile's view it belongs to. */ + private suspend fun allHostKeyRows(sessionId: String): Int = + listOf(alice, bob).sumOf { + db.dkgSessionDao().countMessagesByKind(sessionId, it, DkgRitualEvents.HOST_KEY) + } +} diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index e3efe414..2f95bb8a 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -736,7 +736,8 @@ the reason is the interesting part: P1 signed before P2 was a possibility. See ## Found while reading -Six things, none caused by this, all worth knowing. +Seven things, none caused by this, all worth knowing. Two have since been fixed, +and say so. **A built, unreachable path would put any group-signed event on public relays.** `BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in @@ -745,17 +746,33 @@ one button. Argued above as the listed again here because it is a fact about the tree rather than about this design, and the next person to wire a broadcast button will not be reading this note. -**`ChatRoom` is keyed by the group id alone, not by (group, profile).** So two -profiles on one device cannot both hold one group — the second one's Welcome is -refused by `679d5249` — while rows belonging to different profiles sit in one database -with nothing scoping a read by id across them. +**`ChatRoom` is keyed by the group id alone, not by (group, profile).** Rows belonging +to different profiles sit in one database with nothing scoping a read by id across +them. `679d5249` refuses the second profile's *Welcome*, so two of them cannot both +hold one **Marmot** group — but a NIP-17 room has no Welcome and no equivalent check, +and a group's own key ceremony is held in exactly such a room. So two profiles both +taking part in one ceremony was reachable, and the room is still keyed by the group id. -**`FrostSigningManager.canSign` is device-wide, not profile-wide.** -`getKeyHoldingSessions()` carries no identity predicate although `DkgSession` has the -column, so the active profile can resolve a key whose share another profile -generated. Device-local and pre-existing, and a profile-isolation gap rather than a -cross-user one, but it is load-bearing for every "can this act" check in the app and -not only for the ones here. +**`FrostSigningManager.canSign` was device-wide, not profile-wide — fixed.** +`getKeyHoldingSessions()` carried no identity predicate although `DkgSession` had the +column, so the active profile could resolve a key whose share another profile +generated. Device-local and a profile-isolation gap rather than a cross-user one, but +load-bearing for every "can this act" check in the app and not only for the ones here. +`completedKey` and `canSign` now take the asking profile and scope all three of their +answers by it; `ProfileScopedSigningKeyJvmTest` covers the three routes and the +repository seam the UI reads. + +**`DkgSession` was keyed by the ceremony alone — fixed, schema 22.** The id is minted +by the coordinator and travels on the wire, so it is one string for every participant, +and two profiles are two participants: `acceptProposal` returned the row the first of +them wrote and the second ran the ritual out of it, behind the first's host key, its +randomness and in the end its share. The key is `(id, userPublicKey)` now, and +`DkgParticipantMessage` carries the receiving profile for the same reason — a message +is an input to *that* profile's recomputation, and the cascade needs a unique parent to +point at. `TwoProfilesInOneCeremonyJvmTest` runs both profiles through one ceremony in +one database; `DkgSessionPerProfileMigrationJvmTest` covers the rebuild with foreign +keys on, which is the setting under which a mis-ordered drop would take the messages +with it. **`ChatRoom.parentChatRoomId` is write-only in production.** `MarmotGroupCreation` sets it; its only reader is