diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cf291845..56eed7fb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -121,6 +121,10 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + // runTest: the DAO, model and relay layers are all suspending, and there is no + // runBlocking in a common source set — so anything worth asserting about them + // needs a coroutine, and a scheduler, to assert it in. + implementation(libs.kotlinx.coroutinesTest) } jvmMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json new file mode 100644 index 00000000..02a05a59 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/7.json @@ -0,0 +1,5379 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "4b5d394b56639e56d8de1e8f9a7f6faf", + "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, `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": "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" + ] + }, + "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, `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": "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, `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 )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "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", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, `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, `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": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": "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_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": "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, `stage` TEXT NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, `signature` 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": "stage", + "columnName": "stage", + "affinity": "TEXT", + "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": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, '4b5d394b56639e56d8de1e8f9a7f6faf')" + ] + } +} \ No newline at end of file diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json new file mode 100644 index 00000000..9e9fc250 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/8.json @@ -0,0 +1,5389 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "1502cdf3274007ce762825646ef3c955", + "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, `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": "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" + ] + }, + "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, `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": "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, `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 )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "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", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, `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, `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": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": "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_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": "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, `stage` TEXT NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, `signature` 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": "stage", + "columnName": "stage", + "affinity": "TEXT", + "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": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, `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": "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, '1502cdf3274007ce762825646ef3c955')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt new file mode 100644 index 00000000..7f833932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/AppLifecycle.kt @@ -0,0 +1,32 @@ +package press.mantra.compose + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the app is in the foreground, for the parts of the app that hold network + * connections open. + * + * A singleton rather than something threaded through the composition, because the consumers + * are not composables: they are long-lived coroutines at application scope, started before + * any screen exists and outliving all of them. + * + * Defaults to foreground. Nothing here can observe a platform that has not wired + * [enteredForeground]/[enteredBackground] up, and on such a platform "always on" is the + * behaviour that predates this file — a subscription that never opens is a far worse failure + * than one that stays open too long. + */ +object AppLifecycle { + private val _isForeground = MutableStateFlow(true) + + val isForeground: StateFlow = _isForeground.asStateFlow() + + fun enteredForeground() { + _isForeground.value = true + } + + fun enteredBackground() { + _isForeground.value = false + } +} 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 29cd80e7..939c4af6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -20,6 +20,7 @@ import press.mantra.compose.database.dao.FrostSigningSessionDao import press.mantra.compose.database.dao.GiftWrapMessageDao import press.mantra.compose.database.dao.GiftWrapPayloadDao import press.mantra.compose.database.dao.GiftWrapSealDao +import press.mantra.compose.database.dao.GroupKeyStateDao import press.mantra.compose.database.dao.InReplyToRelationDao import press.mantra.compose.database.dao.MantraArtifactDao import press.mantra.compose.database.dao.MantraArtifactVersionDao @@ -71,6 +72,7 @@ import press.mantra.compose.database.model.Connection import press.mantra.compose.database.model.GiftWrapMessage import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GiftWrapSeal +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion @@ -132,6 +134,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) GiftWrapMessage::class, GiftWrapSeal::class, GiftWrapPayload::class, + GroupKeyState::class, InReplyToRelation::class, MantraArtifact::class, MantraArtifactVersion::class, @@ -169,7 +172,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 6, + version = 8, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -194,7 +197,18 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // both shapes Room can migrate itself. A ceremony that completed before // this reads back null, and signing falls back to not cross-checking // shares rather than refusing to run. - AutoMigration(from = 5, to = 6) + AutoMigration(from = 5, to = 6), + // v7 adds the GroupKeyState table, which records what shared key a room + // signs with instead of leaving it to be rederived. A new table is a + // shape Room migrates itself. Rooms created before this have no row and + // fall back to the rederivation scan in FrostSigningManager.completedKey, + // which is why that scan stays. + AutoMigration(from = 6, to = 7), + // v8 adds a nullable direct message recipient to MarmotInnerEvent and + // ChatMessage. Nullable additions need no default and drop no data, so Room + // generates this one. Rows written before it come back null, which reads as + // "not a direct message" -- the only answer that is true of all of them. + AutoMigration(from = 7, to = 8) ] ) @ColumnTypeConverters(MantraConverters::class) @@ -216,6 +230,8 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun frostSigningSessionDao(): FrostSigningSessionDao + abstract fun groupKeyStateDao(): GroupKeyStateDao + abstract fun connectionDao(): ConnectionDao abstract fun giftWrapMessageDao(): GiftWrapMessageDao diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt index 1758a1a9..4ac707ba 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatMessageDao.kt @@ -30,6 +30,26 @@ interface ChatMessageDao { @Query("SELECT COUNT(*) FROM ChatMessage WHERE chatRoomId = :chatRoomId AND senderPublicKey = :senderPublicKey") suspend fun countChatMessagesBySenderPublicKey(chatRoomId: HexKey, senderPublicKey: HexKey): Int + /** + * The room's group events that already have something to show for them. + * + * One query so a replay can skip the events it has no business touching without + * a lookup per event -- `marmotGroupEventId` carries no index, so each of those + * is a scan of the whole table. + * + * [unresolvedTypes] is [press.mantra.compose.database.model.ChatMessage.UNRESOLVED_MARMOT_TYPES]: + * lines that stand in for a message still to come rather than reporting one. + */ + @Query( + "SELECT marmotGroupEventId FROM ChatMessage " + + "WHERE chatRoomId = :chatRoomId AND marmotGroupEventId IS NOT NULL " + + "AND messageType NOT IN (:unresolvedTypes)" + ) + suspend fun getResolvedMarmotGroupEventIds( + chatRoomId: String, + unresolvedTypes: Collection + ): List + @Upsert suspend fun upsert(chatMessage: press.mantra.compose.database.model.ChatMessage): Long } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt new file mode 100644 index 00000000..05c43121 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -0,0 +1,56 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Transaction +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.GroupKeyState + +@Dao +abstract class GroupKeyStateDao { + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getByChatRoomId(chatRoomId: String): GroupKeyState? + + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeByChatRoomId(chatRoomId: String): Flow + + /** Every room that signs with one ceremony's key. One, today. */ + @Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId") + abstract suspend fun getByDkgSessionId(dkgSessionId: String): List + + @Upsert + abstract suspend fun upsert(groupKeyState: GroupKeyState) + + /** + * Files a state, keeping the newest announcement per room. + * + * This is where the event's replaceable semantics actually happen. Relays + * never see a `GroupKeyStateEvent` -- it is a rumor inside a Marmot group + * event -- so nothing upstream applies the 3xxxx replacement rule, and an + * announcement that arrives twice would otherwise be two rows racing for + * one primary key. + * + * Older announcements are dropped rather than applied, so a redelivery from + * a relay backfill cannot walk the room back to a state it has already + * moved past. An announcement at the same instant is kept as a no-op: two + * members announcing the same true thing agree by construction, since both + * derived it from the room they are standing in. + * + * Returns the state now on file. + */ + @Transaction + open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + val known = getByChatRoomId(groupKeyState.chatRoomId) + + if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + + val stamped = groupKeyState.copy( + createdAt = known?.createdAt ?: groupKeyState.createdAt, + updatedAt = groupKeyState.createdAt + ) + upsert(stamped) + + return stamped + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt index ac55e82e..bd8f5f82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraDao.kt @@ -9,7 +9,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage -import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk @@ -19,7 +18,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -156,62 +154,6 @@ abstract class MantraDao( } - @Transaction - open suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? { - // TODO: Verify the active user is an admin of the chat room before allowing this. - val artifactEventTemplate = ArtifactEvent.build( - name = name, - url = url, - visibility = visibility, - license = license, - dialectId = dialectId, - ) - - val mantraArtifact = MantraArtifact.fromArtifactEventTemplate( - artifactEventTemplate = artifactEventTemplate, - chatRoomId = localChatRoom.chatRoom.id, - userPublicKey = userPublicKey, - ) ?: return null - - // Persist the artifact locally and submit it to the group. - // - // This covers the "private" visibility case. Permissioned artifacts - // (published as a PublicMessage) and public artifacts (published as a - // plain nostr event) are not implemented yet. - return try { - database.mantraArtifactDao().upsert(mantraArtifact) - - submitToGroup( - chatRoomId = mantraArtifact.chatRoomId, - submitterPublicKey = userPublicKey, - payload = rumorOf(artifactEventTemplate, userPublicKey), - text = "Added $name to artifacts", - ) - - // Every artifact starts with an initial version. - addArtifactVersionInternal( - localChatRoom = localChatRoom, - artifactId = mantraArtifact.id, - versionLabel = versionLabel, - userPublicKey = userPublicKey, - ) - - mantraArtifact - } catch (error: Throwable) { - logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error) - null - } - } - @Transaction open suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt index ca3413e3..e6c5049f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt @@ -22,6 +22,8 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.exporterSecret import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.MarmotInboundManager.EPOCH_RETENTION_WINDOW +import press.mantra.compose.nostr.MarmotDelivery +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag @@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher @@ -435,17 +438,13 @@ abstract class MarmotOutboundDao( // Save commitResult... in case we need to broadcast welcomeEvent after relay acknowledgement... database.marmotCommitResultDao().upsert( - MarmotCommitResult( - id = commitEvent.id, - isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + MarmotCommitResult.from( + commitEventId = commitEvent.id, + commitResult = commitResult, chatRoomId = nostrGroupId, - commitBytes = commitResult.commitBytes, - preCommitExporterSecret = commitResult.preCommitExporterSecret, - welcomeBytes = commitResult.welcomeBytes, - framedCommitBytes = commitResult.preCommitExporterSecret, - groupInfoBytes = commitResult.groupInfoBytes, userPublicKey = userPublicKey, peerKeyPackageEventId = peerKeyPackage.id, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, createdAt = Instant.fromEpochSeconds(commitEvent.createdAt) ) ) @@ -545,7 +544,20 @@ abstract class MarmotOutboundDao( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync ): GroupEvent { - val innerEvent = RumorAssembler.assembleRumor( + // A direct message is gift wrapped for one member before it goes into MLS, so + // what the group carries is a kind:1059 whose id is not this row's. Everything + // downstream -- MLS encrypt, outer ChaCha20, kind:445, persistence, broadcast -- + // is the same either way. See MarmotDirectMessage. + val innerEvent: Event = marmotInnerEvent.directMessageRecipientPublicKey?.let { recipient -> + MarmotDirectMessage.wrap( + signer = nostrSignerSync, + recipientPublicKey = recipient, + kind = marmotInnerEvent.kind, + createdAt = marmotInnerEvent.createdAt.epochSeconds, + tags = marmotInnerEvent.tags, + content = marmotInnerEvent.content + ) + } ?: RumorAssembler.assembleRumor( pubKey = nostrSignerSync.pubKey, ev = EventTemplate( createdAt = marmotInnerEvent.createdAt.epochSeconds, @@ -628,13 +640,38 @@ abstract class MarmotOutboundDao( database.marmotInnerEventDao().upsert( marmotInnerEvent.copy( - marmotGroupEventId = groupEvent.id + marmotGroupEventId = groupEvent.id, + // A direct message's plaintext is already on its ChatMessage row, which is + // the sender's only copy of it. A second one here would be cleartext left + // in a table that otherwise holds nothing but wire events. + content = if (marmotInnerEvent.directMessageRecipientPublicKey != null) { + "" + } else { + marmotInnerEvent.content + } ) ) - val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(innerEvent.id) + // Keyed on the queued row, not on `innerEvent.id`. For a direct message those + // differ -- the row is the rumor, the wire event is the wrap built around it -- + // and the wrap's id matches no ChatMessage, so this lookup comes back null. That + // costs only the transcript linkage, not the send. + val chatMessageOrNull = database.chatMessageDao().getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id) logger.d("chatMessageOrNull: $chatMessageOrNull") + + val delivery = MarmotDelivery.plan( + groupEventId = groupEvent.id, + relays = Relays.DefaultDMRelayList, // TODO: Get these from localChatRoom... + chatMessage = chatMessageOrNull, + ) + + // Sync broadcast to all the required relays. Unconditional, and before any + // transcript bookkeeping: see MarmotDelivery for what gating it on a chat line + // cost the signing sessions that have none. + val broadcastNostrEventRequestIds = + database.broadcastNostrEventRequestDao().insert(delivery.broadcasts) + chatMessageOrNull?.let { chatMessage -> database.chatMessageNostrEventRelationDao().upsert( ChatMessageNostrEventRelation( @@ -649,16 +686,6 @@ abstract class MarmotOutboundDao( ) ) - // Sync broadcast to all the required relays... - val broadcastNostrEventRequestIds = database.broadcastNostrEventRequestDao().insert( - Relays.DefaultDMRelayList.map { // TODO: Get these from localChatRoom... - BroadcastNostrEventRequest( - nostrEventId = groupEvent.id, - relayURL = it.url - ) - } - ) - broadcastNostrEventRequestIds.forEach { broadcastNostrEventRequestId -> database.chatMessageBroadcastNostrEventRequestRelationDao().upsert( ChatMessageBroadcastNostrEventRequestRelation( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 0aaba9be..5a665c9c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -19,6 +19,7 @@ import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.Post import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.exceptions.GiftWrapImpersonationException import press.mantra.compose.exceptions.GiftWrapSealDecryptionException @@ -32,15 +33,20 @@ import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MlsGroupCache import press.mantra.compose.managers.MarmotInboundManager +import press.mantra.compose.managers.MarmotReindexSweep import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.CommitOrdering +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle @@ -386,114 +392,11 @@ abstract class NostrDao( logger.d("groupEvent: ${groupEvent.toJson()}") groupEvent.groupId()?.let { chatRoomId -> - val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) - - if (localChatRoom != null) { - // Through the cache rather than rebuilt here, so the secret - // tree's skipped-generation keys survive from one message to - // the next. Two events published in the same instant arrive in - // whatever order the relay feels like, and rebuilding between - // them loses the earlier one for good -- see MlsGroupCache. - val handled = MlsGroupCache.withGroup( - chatRoomId = chatRoomId, - storedStateHex = localChatRoom.chatRoom.mlsGroupState, - build = { localChatRoom.chatRoom.toMlsGroup() }, - save = { stateHex -> - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy(mlsGroupState = stateHex) - ) - } - ) { mlsGroup -> - val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> - - val pubkey = when (val cred = leafNode.credential) { - is Credential.Basic -> cred.identity.toHexKey() - else -> null - } - - logger.d("leafIndex=$leafIndex pubKey=$pubkey") - pubkey - } - - if (memberPubkeys.contains(activeKeyPair.pubKey.toHex())) { - database.marmotGroupEventDao().upsert( - MarmotGroupEvent( - id = groupEvent.id, - userPublicKey = activeKeyPair.pubKey.toHex(), - publicKey = groupEvent.pubKey, - encryptedContent = groupEvent.encryptedContent(), - chatRoomId = chatRoomId, - createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), - expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) }, - signature = groupEvent.sig - ) - ) - MarmotInboundManager.processGroupEvent( - database = database, - activeKeyPair = activeKeyPair, - localChatRoom = localChatRoom, - mlsGroup = mlsGroup, - groupEvent = groupEvent - )?.let { groupEventResult -> - logger.d("groupEventResult: $groupEventResult") - - when(groupEventResult) { - is GroupEventResult.CommitProcessed -> { - MarmotInboundManager.processGroupMembershipChanges( - database = database, - mlsGroup = mlsGroup, - localChatRoom = localChatRoom - ) - } - else -> { - logger.i("No app logic to handle: $groupEventResult") - } - } - - ChatMessage.fromGroupEventResult( - database = database, - activeKeyPair = activeKeyPair, - groupEvent = groupEvent, - groupEventResult = groupEventResult, - )?.let { chatMessage -> - logger.d("chatMessage: $chatMessage") - database.chatMessageDao().upsert( - chatMessage - ) - } - - // A FROST signing message for this group. Driven from - // here rather than from ChatMessage because the - // manager needs the room to publish its own replies - // into, and because it writes its transcript lines - // itself. The manager is idempotent, so a redelivered - // message re-runs a step it has already taken. - if (groupEventResult is GroupEventResult.ApplicationMessage) { - Event.fromJsonOrNull(groupEventResult.innerEventJson) - ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } - ?.let { innerEvent -> - FrostSigningManager.processSigningPayload( - database = database, - localChatRoom = localChatRoom, - innerEvent = innerEvent, - userPublicKey = activeKeyPair.pubKey.toHex() - ) - } - } - } - } else { - throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") - } - } - - // Null means the room has no usable group state, which is what - // a failed toMlsGroup() meant before the cache existed. - if (handled == null) { - throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent") - } - } else { - throw MarmotMissingChatGroupException("Couldn't find chatRoom for $nostrEvent") - } + indexMarmotGroupEvent( + groupEvent = groupEvent, + chatRoomId = chatRoomId, + activeKeyPair = activeKeyPair, + ) } } @@ -534,6 +437,15 @@ abstract class NostrDao( giftWrapMessage ) + if (!giftWrapMessage.isAddressedTo(activeKeyPair)) { + // Undecryptable by design rather than by failure, so keep the event and + // the wrap we just stored and stop here. Throwing would roll the whole + // transaction back and lose both. + logger.d("GiftWrap ${nostrEvent.id} is addressed to ${giftWrapMessage.receiverPublicKey}, nothing to index") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> @@ -672,41 +584,14 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair( - "p", - listOf(participant.participantPublicKey) - ) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao() - .insert( - chatMessageRelayListEvent.relays() - .map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // Nothing to sync when we do have the relay list: a gift + // wrap is authored by a throwaway key, so authors=us matched + // nothing and this request was always empty. Our own inbox is + // synced by p-tag instead — by the live gift wrap + // subscription, which also picks up the copy we wrap to + // ourselves (createNip17ChatRoom puts us in our own + // participant list), on every device. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... @@ -908,35 +793,9 @@ abstract class NostrDao( } } - if (chatMessageRelayListEvent != null) { - // Sync messages from this relay that were sent by us - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - authors = arrayOf( - userPublicKey - ), - tags = mapOf( - Pair("p", listOf(participant.participantPublicKey)) - ), - limit = 50 - ) - database.negentropySynchronizeRequestDao().insert( - chatMessageRelayListEvent.relays().map { normalizedRelayUrl -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = normalizedRelayUrl.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "sent-messages", - synchronizationFilter = synchronizationFilter, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - ) - } else { + // See the identical block above: authors=us never matches a + // gift wrap, so only the missing-relay-list case has work to do. + if (chatMessageRelayListEvent == null) { logger.w("We don't have a chatMessageRelayListEvent for the pubkey $publicKey") // Sync ChatMessageRelayListEvent publicKey... @@ -1279,6 +1138,256 @@ abstract class NostrDao( } } + /** + * Reads one kind:445 against the room's MLS group and files whatever it turns + * out to be: a message, a commit that advances the group, an entity for the + * library, or a placeholder saying it could not be read yet. + * + * Its own function so a replay can run exactly what a first delivery ran -- + * see [reindexMarmotGroupEvents]. Throws for a room this device cannot process + * the event against at all, which the caller decides what to do about: a first + * delivery lets it roll back its transaction, a replay logs it and moves on. + */ + private suspend fun indexMarmotGroupEvent( + groupEvent: GroupEvent, + chatRoomId: String, + activeKeyPair: KeyPair, + ) { + val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId) + ?: throw MarmotMissingChatGroupException("Couldn't find chatRoom for ${groupEvent.id}") + + // Through the cache rather than rebuilt here, so the secret + // tree's skipped-generation keys survive from one message to + // the next. Two events published in the same instant arrive in + // whatever order the relay feels like, and rebuilding between + // them loses the earlier one for good -- see MlsGroupCache. + val handled = MlsGroupCache.withGroup( + chatRoomId = chatRoomId, + storedStateHex = localChatRoom.chatRoom.mlsGroupState, + build = { localChatRoom.chatRoom.toMlsGroup() }, + save = { stateHex -> + database.chatRoomDao().upsert( + localChatRoom.chatRoom.copy(mlsGroupState = stateHex) + ) + } + ) { mlsGroup -> + val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> + + val pubkey = when (val cred = leafNode.credential) { + is Credential.Basic -> cred.identity.toHexKey() + else -> null + } + + logger.d("leafIndex=$leafIndex pubKey=$pubkey") + pubkey + } + + if (memberPubkeys.contains(activeKeyPair.pubKey.toHex())) { + database.marmotGroupEventDao().upsert( + MarmotGroupEvent( + id = groupEvent.id, + userPublicKey = activeKeyPair.pubKey.toHex(), + publicKey = groupEvent.pubKey, + encryptedContent = groupEvent.encryptedContent(), + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), + expiresAt = groupEvent.expiration()?.let { Instant.fromEpochSeconds(it) }, + signature = groupEvent.sig + ) + ) + MarmotInboundManager.processGroupEvent( + database = database, + activeKeyPair = activeKeyPair, + localChatRoom = localChatRoom, + mlsGroup = mlsGroup, + groupEvent = groupEvent + )?.let { groupEventResult -> + logger.d("groupEventResult: $groupEventResult") + + when(groupEventResult) { + is GroupEventResult.CommitProcessed -> { + MarmotInboundManager.processGroupMembershipChanges( + database = database, + mlsGroup = mlsGroup, + localChatRoom = localChatRoom + ) + } + else -> { + logger.i("No app logic to handle: $groupEventResult") + } + } + + ChatMessage.fromGroupEventResult( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + groupEventResult = groupEventResult, + // Who sent it comes from the MLS frame, not the + // payload. Resolved here because this is where the + // group is; the leaf index is on the result, and the + // tree has not moved -- an application message + // advances no epoch. + senderIdentity = (groupEventResult as? GroupEventResult.ApplicationMessage) + ?.let { mlsGroup.memberIdentityHex(it.senderLeafIndex) }, + )?.let { chatMessage -> + logger.d("chatMessage: $chatMessage") + persistMarmotChatMessage(chatMessage) + } + + // A FROST signing message for this group. Driven from + // here rather than from ChatMessage because the + // manager needs the room to publish its own replies + // into, and because it writes its transcript lines + // itself. The manager is idempotent, so a redelivered + // message re-runs a step it has already taken. + if (groupEventResult is GroupEventResult.ApplicationMessage) { + Event.fromJsonOrNull(groupEventResult.innerEventJson)?.let { innerEvent -> + when { + FrostSigningEvents.isFrostSigningKind(innerEvent.kind) -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + + // What key this room signs with. Filed + // rather than acted on, and only after + // the room rederives from the key it + // names -- the manager drops anything + // that does not, whoever sent it. + GroupKeyStateEvent.isGroupKeyStateKind(innerEvent.kind) -> + GroupKeyStateManager.record( + database = database, + chatRoomId = localChatRoom.chatRoom.id, + innerEvent = innerEvent + ) + } + } + } + } + } else { + throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}") + } + } + + // Null means the room has no usable group state, which is what + // a failed toMlsGroup() meant before the cache existed. + if (handled == null) { + throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for ${groupEvent.id}") + } + } + + /** + * Files the chat line a kind:445 produced, replacing the line that event + * already has rather than adding a second one. The rule for which of the two + * wins is [ChatMessage.reconcileMarmotLine]. + */ + private suspend fun persistMarmotChatMessage(chatMessage: ChatMessage) { + val existing = chatMessage.marmotGroupEventId?.let { + database.chatMessageDao().getChatMessagesByMarmotGroupEventId(it) + } + + val row = ChatMessage.reconcileMarmotLine(fresh = chatMessage, existing = existing) + + if (row == null) { + logger.d("Keeping ${existing?.messageType} line for ${chatMessage.marmotGroupEventId}") + return + } + + database.chatMessageDao().upsert(row) + } + + /** + * Reads a room's stored kind:445 events again, oldest first. + * + * Relays impose no ordering, so a group event can arrive before the group is + * able to read it: an application message encrypted under an epoch whose commit + * has not landed yet, or a commit for an epoch ahead of the local one. Both are + * stored and then dropped -- `MarmotInboundManager` refuses to apply an + * out-of-epoch commit precisely so it does not half-mutate the group -- and + * nothing revisits them once the missing event fills the gap. This is the + * revisit, driven from the group's detail screen. + * + * Only events with nothing to show for them are replayed: no chat line at all, + * or one of [ChatMessage.UNRESOLVED_MARMOT_TYPES]. Everything already read is + * left untouched, which is what keeps the replay from disturbing a room where + * there was nothing wrong. Within that set a replay is safe to repeat: the ids + * of every row it writes are derived from the events themselves, commits that + * are behind the current epoch are rejected as duplicates before they can touch + * the group, and the one row with a generated id is reconciled by + * [persistMarmotChatMessage]. + * + * Passes repeat while a pass recovers something -- see [MarmotReindexSweep] -- + * because created_at order is not necessarily epoch order, and a commit + * recovered by one pass can be what lets the next read the messages that were + * waiting on it. + * + * What this cannot do is recover a message whose key is gone: an application + * message the ratchet has already advanced past, or one from an epoch that + * predates this device joining. Those stay unreadable however often they are + * replayed. + */ + open suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + activeKeyPair: KeyPair, + ): MarmotReindexReport { + val userPublicKey = activeKeyPair.pubKey.toHex() + + // The query's LIKE only narrows; the h tag is what decides the room. Sorted + // by CommitOrdering's own comparator rather than left in createdAt order, so + // that where two commits compete for an epoch the first one replayed is the + // one every other member also picked. + val groupEvents = database.nostrEventDao() + .getMarmotGroupNostrEventsByChatRoomId(chatRoomId) + .mapNotNull { storedNostrEvent -> + storedNostrEvent + .toGroupEvent(userPublicKey = userPublicKey) + ?.takeIf { it.groupId() == chatRoomId } + } + .sortedWith(CommitOrdering.comparator) + + logger.i("Reindex $chatRoomId: ${groupEvents.size} stored group event(s)") + + // Held commits are what a replay is most often for, and they are only ever + // cleared by one applying -- see MarmotInboundManager.forgetPendingCommits. + MarmotInboundManager.forgetPendingCommits(chatRoomId) + + val resolved = database.chatMessageDao().getResolvedMarmotGroupEventIds( + chatRoomId = chatRoomId, + unresolvedTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES, + ).toSet() + + return MarmotReindexSweep.run( + stored = groupEvents.size, + unresolved = groupEvents.filterNot { it.id in resolved }, + replay = { groupEvent -> + indexMarmotGroupEvent( + groupEvent = groupEvent, + chatRoomId = chatRoomId, + activeKeyPair = activeKeyPair, + ) + }, + isUnresolved = { groupEvent -> isUnresolvedMarmotGroupEvent(groupEvent.id) }, + ).also { + logger.i("Reindex $chatRoomId: $it") + } + } + + /** + * Whether one group event still has nothing to show for it, asked again after + * a replay to see whether that replay achieved anything. + * + * Some events can never leave this state and will be swept every time: a commit + * rejected as a duplicate writes no line, and neither does a commit this device + * sent, whose line was written against the send rather than the event. Both cost + * one refused decrypt per sweep, which is cheaper than a bookkeeping column that + * would have to be migrated in. + */ + private suspend fun isUnresolvedMarmotGroupEvent(groupEventId: String): Boolean { + val chatMessage = database.chatMessageDao().getChatMessagesByMarmotGroupEventId(groupEventId) + return chatMessage == null || chatMessage.messageType in ChatMessage.UNRESOLVED_MARMOT_TYPES + } /** * Files an arriving NIP-17 chat message so it shows up in the room's feed. @@ -1425,4 +1534,5 @@ abstract class NostrDao( } } } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index e813241b..61775857 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -143,6 +143,26 @@ interface NostrEventDao { now: Instant = Clock.System.now(), ): List + /** + * Every kind:445 held locally that mentions [chatRoomId], oldest first. + * + * The `LIKE` is a prefilter over the serialised tags, not the test: it can match + * a group id that happens to appear in some other tag, so callers must confirm + * the event's own `h` tag before treating it as this room's. Ordered ascending + * because a replay has to apply commits in the order they were sent. + * + * Deliberately not a join on `MarmotGroupEvent`: an event whose indexing failed + * part way may have been stored without ever reaching that table, and those are + * exactly the ones worth replaying. + */ + @Transaction + @Query( + "SELECT * FROM NostrEvent WHERE kind = ${GroupEvent.KIND} " + + "AND tags LIKE '%' || :chatRoomId || '%' " + + "ORDER BY createdAt ASC" + ) + suspend fun getMarmotGroupNostrEventsByChatRoomId(chatRoomId: HexKey): List + @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND pubKey in (:authors) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") suspend fun getAuthoredNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index feba4e8c..0e834a90 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -18,6 +18,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -30,6 +31,10 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.nostr.MarmotDirectMessage import kotlin.time.Clock import kotlin.time.Instant @@ -79,6 +84,15 @@ data class ChatMessage( val content: String, val messageType: String = "message", + /** + * Who a direct message was for, or null for anything else. + * + * Carried on the row rather than read back off the wrap so the transcript can name + * the recipient without a join, on both the line the two of them can read and the + * line everybody else gets. See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = Clock.System.now(), @@ -87,6 +101,21 @@ data class ChatMessage( ): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity { companion object { + private val logger = Logger.withTag("ChatMessage") + + /** + * A one-to-one message inside a group, as a line in the group's chat. + * + * Written by both sides of the room. For its two parties [content] is the words; + * for everybody else it is empty, and the line says only that a private message + * was sent and to whom -- which is all the group can learn, and all it should. + * + * [directMessageRecipientPublicKey] is set on every one of these regardless of + * who can read it, because naming the recipient is the point of the line the + * bystanders get. See docs/marmot-direct-messages.md. + */ + const val TYPE_DIRECT_MESSAGE = "directMessage" + /** * A ChillDKG ritual message, as a line in the group's chat. * @@ -212,6 +241,21 @@ data class ChatMessage( /** The signing lines that ask rather than report. */ val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED) + /** + * The signing lines that end a session, whichever way it went. + * + * A request usually stops being one by being answered, but that is not + * the only way. A member who declines publishes nothing, and a quorum + * that signs without them asks them for nothing further -- from the + * transcript both look the same, nothing of the reader's own either + * side of the request, so the session's own ending is the only thing + * left to read it from. + * + * A ceremony has no equivalent because a ceremony step can only be + * taken or waited for. Signing is the one thing a member can refuse. + */ + val FROST_SETTLEMENTS = setOf(TYPE_FROST_COMPLETE, TYPE_FROST_FAILED) + /** Every signing line, for rendering them as system lines rather than bubbles. */ val FROST_TYPES = setOf( TYPE_FROST_STARTED, @@ -249,11 +293,236 @@ data class ChatMessage( TYPE_DKG_FAILED, ) + /** + * A kind:445 arrived that the group could not read at the time. + * + * Both are placeholders for a message still to come: the outer layer was + * encrypted under an epoch whose exporter secret we did not hold yet, or + * the commit is one of several competing for an epoch and none has been + * applied. Neither says anything a member wrote. + */ + const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" + const val TYPE_PENDING_COMMIT = "pendingCommit" + + /** + * The lines a reindex is allowed to replace. + * + * A group event that left one of these behind was never read, so replaying + * it can only improve on what is there. Every other line -- a message, an + * applied commit, anything this device sent -- is the final word on its + * group event and a replay must leave it alone. + */ + val UNRESOLVED_MARMOT_TYPES = setOf( + TYPE_UNDECRYPTABLE_OUTER_LAYER, + TYPE_PENDING_COMMIT, + ) + + /** + * The row to write for a group event that was just read, given the line that + * event already has. Null means leave what is there alone. + * + * [ChatMessage.id] is autogenerated, so writing [fresh] as it comes always + * inserts. That is right the first time a group event is read and wrong every + * time after: a replay would leave the room showing each recovered message + * twice, once as "Undecryptable Message" and once as itself. A group event has + * exactly one line and a deterministic id, so [existing] is what makes a + * re-read an update instead. + * + * A placeholder never overwrites a line that says something. A group event + * this device sent already has its line, written on the way out, and a replay + * that cannot read back our own ratchet-consumed message must not replace it + * with "Undecryptable Message". + * + * [viewedAt] and [savedAt] stay with [existing]: when a line was first seen is + * a fact about the reader, not about the event, and a re-read is not a new + * arrival. Its id stays too, so the broadcast and nostr-event relations + * pointing at that row keep pointing at it. + */ + fun reconcileMarmotLine( + fresh: ChatMessage, + existing: ChatMessage?, + ): ChatMessage? { + if (existing == null) return fresh + + val freshIsPlaceholder = fresh.messageType in UNRESOLVED_MARMOT_TYPES + val existingIsPlaceholder = existing.messageType in UNRESOLVED_MARMOT_TYPES + + if (freshIsPlaceholder && !existingIsPlaceholder) return null + + return fresh.copy( + id = existing.id, + savedAt = existing.savedAt, + viewedAt = existing.viewedAt, + ) + } + + /** + * The request lines this device has since answered, by row id. + * + * A request is answered when the step it asked for has been published by + * this device -- which is exactly what approving it does. The transcript + * already records that as an authored line, so the answer is read from + * the list rather than from the session, and it stays right for a room + * that has run more than one ceremony. + */ + fun answeredRequests(messages: List): Set = messages + .mapNotNull { request -> + val published = (DKG_REQUEST_FULFILMENTS + FROST_REQUEST_FULFILMENTS)[request.messageType] + ?: return@mapNotNull null + + val done = messages.any { + it.messageType == published && + it.isUserMessage && + it.createdAt >= request.createdAt + } + + request.id.takeIf { done } + } + .toSet() + + /** + * The signing requests that are no longer open, by row id. + * + * Not the same thing as [answeredRequests], and deliberately kept apart + * from it: these are requests the reader never answered, so they have + * earned no tick and claiming otherwise would credit them with a + * signature they refused or were never needed for. What they have run + * out of is a decision to make -- see [FROST_SETTLEMENTS]. + */ + fun settledRequests(messages: List): Set = messages + .filter { request -> + request.messageType in FROST_REQUEST_TYPES && + messages.any { + it.messageType in FROST_SETTLEMENTS && it.createdAt >= request.createdAt + } + } + .map { it.id } + .toSet() + + /** + * Files one direct message, from whichever side of it this device is on. + * + * The decision is [MarmotDirectMessage.classify]'s, which is where it is tested; + * this turns it into rows. See docs/marmot-direct-messages.md. + */ + private suspend fun directMessage( + database: MantraDatabase, + activeKeyPair: KeyPair, + groupEvent: GroupEvent, + chatRoomId: String, + wrap: Event, + senderIdentity: HexKey? + ): ChatMessage? { + val recipientPublicKey = wrap.tags.firstTagValue("p") + + return when (val delivery = MarmotDirectMessage.classify(wrap, activeKeyPair, senderIdentity)) { + // Our own words coming back. We cannot open our own wrap -- its key was + // discarded at send time -- so this is indistinguishable from a + // bystander's view by decryption alone, and filing it as one would + // replace the words on the row `sendChatMessage` wrote with an empty + // placeholder. That row is the only copy of them that exists. + // `NostrDao.persistInboundChatMessage` carries the same guard, for the + // same reason, on the NIP-17 path. + MarmotDirectMessage.Delivery.Ours -> { + logger.d("Direct message ${wrap.id} is ours; the row written on send is the only copy") + null + } + + // A forged or malformed message costs its own line and nothing else. It + // is dropped rather than thrown because the caller is inside + // storeNostrEvent's transaction, and this must not cost the whole event. + is MarmotDirectMessage.Delivery.Rejected -> { + logger.e("Dropping direct message ${wrap.id}: ${delivery.reason}") + null + } + + // Not ours to read. The group is meant to see that a private message was + // sent and to whom, and nothing more. + MarmotDirectMessage.Delivery.Unreadable -> { + // Relays redeliver and negentropy re-syncs; the wrap yields the same + // id every time, so this is what keeps a second delivery from becoming + // a second line. ChatMessage.id is autogenerated and would take a + // duplicate. + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(wrap.id) != null) { + return null + } + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = wrap.id, + senderPublicKey = senderIdentity ?: wrap.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + content = "" + ) + } + + is MarmotDirectMessage.Delivery.Readable -> { + val opened = delivery.opened + + if (database.chatMessageDao().getChatMessagesByMarmotInnerEventId(opened.rumor.id) != null) { + return null + } + + // Keyed on the rumor, not the wrap. This is the id the sender queued, + // so both sides of the conversation hold the same message under the + // same identity. + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = opened.rumor.id, + publicKey = opened.rumor.pubKey, + marmotGroupEventId = groupEvent.id, + tags = opened.rumor.tags, + content = opened.rumor.content, + chatRoomId = chatRoomId, + kind = opened.rumor.kind, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt) + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_DIRECT_MESSAGE, + marmotGroupEventId = groupEvent.id, + marmotInnerEventId = opened.rumor.id, + senderPublicKey = opened.seal.pubKey, + directMessageRecipientPublicKey = recipientPublicKey, + isUserMessage = false, + chatRoomId = chatRoomId, + createdAt = Instant.fromEpochSeconds(opened.rumor.createdAt), + content = opened.rumor.content + ) + } + } + } + + /** + * [senderIdentity] is the MLS credential identity of the leaf that sent this, + * resolved by the caller, which holds the group. It is not derivable from the + * payload: a direct message's wrap is keyed to a throwaway key and names nobody, + * and even for kinds that do carry a pubkey the MLS frame is the authenticated + * source while the field is merely asserted. + * + * It is also the only thing [ChatMessage.isUserMessage] can honestly be computed + * from. Every arm below used to compare the active key against `groupEvent.pubKey` + * -- the kind:445's signer, which is a fresh random ephemeral key on every send + * (`NostrSignerInternal(KeyPair())` in `encryptAndSendMarmotInnerEvent`). That can + * never equal anybody's identity, so it was false for every Marmot message in + * every room, and every one of them rendered as somebody else's. + * + * Null for results that carry no leaf index -- the commit and proposal statuses -- + * which yields false, exactly as before. + */ suspend fun fromGroupEventResult( database: MantraDatabase, activeKeyPair: KeyPair, groupEvent: GroupEvent, - groupEventResult: GroupEventResult + groupEventResult: GroupEventResult, + senderIdentity: HexKey? = null ): ChatMessage? { return when(groupEventResult) { is GroupEventResult.ApplicationMessage -> { @@ -293,6 +562,20 @@ data class ChatMessage( ) ) + // A direct message is not a nip30303 payload to apply. It is + // gift wrapped for one member, and what this device does with it + // depends only on whether its key opens it. + if (event.kind == GiftWrapEvent.KIND) { + return directMessage( + database = database, + activeKeyPair = activeKeyPair, + groupEvent = groupEvent, + chatRoomId = groupEventResult.groupId, + wrap = event, + senderIdentity = senderIdentity + ) + } + // A submission whose payload will not parse, or which carries // another submission, is kept but not applied: there is nothing // here we can turn into a row. @@ -303,7 +586,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(event.createdAt), content = event.content, @@ -316,7 +599,7 @@ data class ChatMessage( marmotGroupEventId = groupEvent.id, marmotInnerEventId = event.id, senderPublicKey = event.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), createdAt = Instant.fromEpochSeconds(event.createdAt), ) } @@ -326,9 +609,9 @@ data class ChatMessage( giftWrapPayloadId = null, marmotGroupEventId = groupEvent.id, marmotInnerEventId = null, - messageType = "pendingCommit", + messageType = TYPE_PENDING_COMMIT, senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Pending Commit in epoch ${groupEventResult.epoch}", // TODO: Figure out what to do here... @@ -341,7 +624,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "processedCommit", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Processed Commit in epoch ${groupEventResult.newEpoch}", // TODO: Figure out what to do here... @@ -354,7 +637,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "duplicate", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -367,7 +650,7 @@ data class ChatMessage( // groupEventId = groupEvent.id, // messageType = "error", // senderPublicKey = groupEvent.pubKey, -// isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, +// isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), // chatRoomId = groupEventResult.groupId, // createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), // content = groupEventResult.innerEventJson, // TODO: Figure out what to do here... @@ -381,7 +664,7 @@ data class ChatMessage( marmotInnerEventId = null, messageType = "proposalStaged", senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Proposal staged: ${groupEventResult.senderLeafIndex}", // TODO: Figure out what to do here... @@ -392,9 +675,9 @@ data class ChatMessage( giftWrapPayloadId = null, marmotGroupEventId = groupEvent.id, marmotInnerEventId = null, - messageType = "undecryptableOuterLayer", + messageType = TYPE_UNDECRYPTABLE_OUTER_LAYER, senderPublicKey = groupEvent.pubKey, - isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey, + isUserMessage = senderIdentity == activeKeyPair.pubKey.toHex(), chatRoomId = groupEventResult.groupId, createdAt = Instant.fromEpochSeconds(groupEvent.createdAt), content = "Undecryptable Message", // TODO: Figure out what to do here... @@ -440,15 +723,17 @@ data class ChatMessage( ) } ArtifactEvent.KIND -> { + val artifactEvent = ArtifactEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + MantraArtifact.fromArtifactEvent( - artifactEvent = ArtifactEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ), + artifactEvent = artifactEvent, chatRoomId = groupId, )?.let { mantraArtifact -> database.mantraArtifactDao().upsert( @@ -457,6 +742,23 @@ data class ChatMessage( ) ) + // An artifact arrives with the version it starts life + // with, derived here rather than sent, so that every + // device holding the artifact holds the same first + // version. Nothing else can hang off an artifact until + // one exists -- a chapter attaches to a version, not to + // an artifact -- so an artifact without one is inert. + MantraArtifactVersion.initialVersionOf( + artifactEvent = artifactEvent, + chatRoomId = groupId, + )?.let { initialVersion -> + database.mantraArtifactVersionDao().upsert( + initialVersion.copy( + marmotGroupEventId = marmotGroupEventId, + ) + ) + } + ChatMessage( giftWrapPayloadId = null, messageType = "artifact", @@ -705,6 +1007,13 @@ data class ChatMessage( // account of the same thing. in FrostSigningEvents.ALL -> null + // The room saying what key it signs with. Standing state rather + // than something that happened, and the room's own id already + // says it to anyone who can derive -- so there is nothing here a + // reader of the transcript needs told. Falling through to + // "unsupported" would put the raw announcement in the chat. + GroupKeyStateEvent.KIND -> null + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index c0b471ea..a7b92840 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -7,13 +7,11 @@ import androidx.room3.PrimaryKey import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlin.time.Clock import kotlin.time.Instant @@ -83,6 +81,18 @@ data class GiftWrapMessage( @Ignore private val logger = Logger.withTag("GiftWrapMessage") + /** + * Whether this gift wrap is addressed to [keyPair], i.e. whether we hold the + * private key that can unwrap it. + * + * NIP-59 encrypts the wrap to its recipient using an ephemeral key that + * [GiftWrapEvent.create] throws away, so a wrap addressed to anyone else can + * never be decrypted by us, not even one we sent ourselves. + */ + fun isAddressedTo( + keyPair: KeyPair + ): Boolean = receiverPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true) + suspend fun decryptGiftWrapSeal( keyPair: KeyPair ): GiftWrapSeal? { @@ -105,19 +115,12 @@ data class GiftWrapMessage( giftWrapEvent.recipientPubKey()?.let { recipientPublicKey -> logger.d("Recipient PublicKey: $recipientPublicKey") - if (keyPair.pubKey.toHexKey() != recipientPublicKey) { - logger.e("We are unwrapping a message we may have sent from ${keyPair.pubKey.toHexKey()}") - - keyPair.privKey?.let { privateKey -> - val sealJSON = Nip44.decrypt( - giftWrapEvent.content, - privateKey = privateKey, - pubKey = giftWrapEvent.pubKey.hexToByteArray() - ) - logger.d("SealJSON: $sealJSON") - - null - } + if (!recipientPublicKey.equals(keyPair.pubKey.toHexKey(), ignoreCase = true)) { + // Not ours to open, and no key we hold ever will be: the wrap is + // encrypted to the recipient with a one-off key that + // GiftWrapEvent.create() discards, so not even the sender can + // unwrap their own gift wrap. + logger.d("GiftWrap $id is addressed to $recipientPublicKey, not to us") } else { val nostrSigner = NostrSignerInternal( keyPair = KeyPair(privKey = keyPair.privKey) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt new file mode 100644 index 00000000..48acc1b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.model.traits.LocalStoreEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * Which shared key a room signs with, as this device has been told. + * + * Written from a `GroupKeyStateEvent` -- the room's own announcement of the + * ceremony behind it -- and read when a signing request arrives, to pick the + * secret share out of the right [DkgSession]. A device that took part in more + * than one ceremony holds more than one share, and they are not + * interchangeable: a partial signature made with the wrong one cannot + * aggregate. + * + * ### Why this is a row and not a rederivation + * + * `FrostSigningManager.completedKey` found the key by walking every ceremony + * this device holds a share for and rederiving each one's room id until one + * matched. That works, and it stays as the fallback for rooms made before this + * table existed, but it can only find rooms derived at the *default* path -- + * the one path the constant names. A room derived anywhere else was invisible + * to it. [derivationPath] is what fixes that, which is also the reason the path + * is stored rather than assumed. + * + * ### Nothing secret lives here + * + * [thresholdPublicKey] is the key signatures verify against, not the secret + * behind it, and [dkgSessionId] is a pointer. The share itself never leaves + * `DkgSession.secretShare` on the device that generated it. + * + * One row per room: a room is derived from one key, and a group that re-runs + * its ceremony derives a different room rather than re-keying this one. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("dkgSessionId"), + ], +) +data class GroupKeyState( + /** The Marmot room this is the key state for. Its id is the derived key. */ + @PrimaryKey + val chatRoomId: String, + + /** + * The ceremony that made the key, and so the row holding this device's + * share of it. + * + * Not a foreign key on purpose. A member can be in the room without + * holding a share -- they were added after the ceremony, or reinstalled -- + * and the state is still worth keeping: it says what the room signs with, + * which is what tells them they cannot. + */ + val dkgSessionId: String, + + /** The group's ChillDKG threshold public key, 33-byte compressed hex. */ + val thresholdPublicKey: HexKey, + + /** The path [chatRoomId] was derived at, `m/9420/0/0` style. */ + val derivationPath: String, + + /** Who announced it. Kept for the transcript; the derivation is what vouches for it. */ + val announcedBy: HexKey, + + /** The announcement's own timestamp, so the newest state per room wins. */ + val announcedAt: Instant, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = createdAt, +): TimestampedEntity, LocalStoreEntity { + /** [derivationPath] as indices, or null if it is not a walkable path. */ + fun pathIndices(): List? = SharedKeyDerivation.parsePathString(derivationPath) + + /** + * Whether this state actually describes the room it claims to. + * + * The room's id is the threshold key derived at the path, so this is the + * whole of the trust model: a state that does not rederive its own room was + * announced by somebody pointing the room at a key it was not made from. + * Checked before the row is written and cheap enough to check again. + */ + fun verifies(): Boolean { + val path = pathIndices() ?: return false + + return runCatching { + SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId + }.getOrDefault(false) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt index 1e7435ef..6cd5c209 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifactVersion.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag @@ -83,6 +84,42 @@ data class MantraArtifactVersion( } companion object { + /** + * The version an artifact starts life with, derived from the artifact. + * + * The group signs an artifact; it does not sign this. So the first + * version cannot be an event proposed on its own -- that would cost a + * second quorum for one form -- and it cannot be invented by whichever + * device notices the artifact first, because an invented id differs on + * every device holding the same artifact and none of them would agree + * about which version a chapter hangs off. Deriving it from the signed + * artifact's own fields gives every device the same row from the same + * bytes, which is the only property that matters here. + * + * It is a rumor -- empty signature -- because nobody signed it. What the + * group signed is the artifact that declares it. + * + * Null when the artifact declares no version, which is every artifact + * written before it did. + */ + fun initialVersionOf( + artifactEvent: ArtifactEvent, + chatRoomId: HexKey, + ): MantraArtifactVersion? { + val versionLabel = artifactEvent.versionLabel() ?: return null + + return fromArtifactVersionEventTemplate( + artifactVersionEventTemplate = ArtifactVersionEvent.build( + content = versionLabel, + createdAt = artifactEvent.createdAt, + ) { + addUnique(ArtifactIdTag.assemble(artifactEvent.id)) + }, + chatRoomId = chatRoomId, + userPublicKey = artifactEvent.pubKey, + ) + } + fun fromArtifactVersionEventTemplate( artifactVersionEventTemplate: EventTemplate, chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt index 160a7a5c..ef749688 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotCommitResult.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.traits.SoftDeletableEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.traits.UserViewableEntity import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlin.time.Clock import kotlin.time.Instant @@ -72,6 +73,40 @@ data class MarmotCommitResult( // TODO: Rename this to GiftWrapPayload... companion object { const val TAG = "MarmotCommitResult" + /** + * The persisted record of a commit, built from the [CommitResult] that produced it. + * + * The five payload fields are carried over from quartz verbatim -- same names, same + * order, same `ByteArray` type on both sides of the copy -- so a value taken from the + * wrong field of the right object typechecks and reaches the database unnoticed. + * `framedCommitBytes = commitResult.preCommitExporterSecret` survived exactly that way, + * storing the group's pre-commit exporter secret in the column documented to hold a + * broadcastable MLS envelope. + * + * Mapping here rather than at the call site means it is written once, in declaration + * order, and pinned by MarmotCommitResultMappingTest. + */ + fun from( + commitEventId: HexKey, + commitResult: CommitResult, + chatRoomId: HexKey, + userPublicKey: HexKey, + peerKeyPackageEventId: HexKey, + isOneMemberInitialGroupCreation: Boolean, + createdAt: Instant, + ): MarmotCommitResult = MarmotCommitResult( + id = commitEventId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + chatRoomId = chatRoomId, + isOneMemberInitialGroupCreation = isOneMemberInitialGroupCreation, + commitBytes = commitResult.commitBytes, + welcomeBytes = commitResult.welcomeBytes, + groupInfoBytes = commitResult.groupInfoBytes, + framedCommitBytes = commitResult.framedCommitBytes, + preCommitExporterSecret = commitResult.preCommitExporterSecret, + createdAt = createdAt, + ) } override fun equals(other: Any?): Boolean { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt index a93087e9..78d628c9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MarmotInnerEvent.kt @@ -78,6 +78,18 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... */ val marmotGroupEventId: String? = null, + /** + * Who this is a direct message to, or null for an ordinary group message. + * + * Set on the way out only. It is what tells the notary to gift wrap this row for one + * member instead of sending it to the whole group, and afterwards what tells the + * transcript the row was private. Inbound rows leave it null -- the recipient is on + * the wrap's `p` tag, which is where every member reads it from. + * + * See docs/marmot-direct-messages.md. + */ + val directMessageRecipientPublicKey: HexKey? = null, + /** * Current time */ @@ -128,6 +140,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... if (quotedEventId != other.quotedEventId) return false if (payloadEventId != other.payloadEventId) return false if (marmotGroupEventId != other.marmotGroupEventId) return false + if (directMessageRecipientPublicKey != other.directMessageRecipientPublicKey) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false if (savedAt != other.savedAt) return false @@ -148,6 +161,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload... result = 31 * result + (quotedEventId?.hashCode() ?: 0) result = 31 * result + (payloadEventId?.hashCode() ?: 0) result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0) + result = 31 * result + (directMessageRecipientPublicKey?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() result = 31 * result + savedAt.hashCode() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt new file mode 100644 index 00000000..0933e6da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/MarmotReindexReport.kt @@ -0,0 +1,27 @@ +package press.mantra.compose.database.model.types + +/** + * What a replay of a room's stored kind:445 events did. + * + * Nothing here is persisted -- it exists so the screen that asked for the replay + * can say what happened rather than leaving the user to guess from the message + * list whether anything moved. + * + * @param stored every kind:445 held locally for the room. + * @param unresolved how many of those had nothing to show for them, or only a + * placeholder line -- the ones a replay was allowed to touch. + * @param recovered how many of [unresolved] came out with something to show: + * a message, an applied commit, an entity added to the group's library. + * @param failed how many threw while being replayed. Expected to be non-zero + * on a room with events from before this device joined, whose epoch secrets it + * never held and never will. + */ +data class MarmotReindexReport( + val stored: Int = 0, + val unresolved: Int = 0, + val recovered: Int = 0, + val failed: Int = 0, +) { + /** Nothing was left to try, so the replay was a no-op. */ + val isNoOp: Boolean get() = unresolved == 0 +} 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 b12e3723..668b201e 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 @@ -10,9 +10,11 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -173,24 +175,58 @@ class DatabaseChatRepository( override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { logger.i("sendChatMessage($text): $localChatRoom") val mlsGroup = localChatRoom.chatRoom.toMlsGroup() if (mlsGroup != null) { - // Create ChatRumor... - val createdAt = Clock.System.now().epochSeconds - val marmotInnerEventKind = if (messageType == ChatMessageEvent.KIND) { - ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind - } else { - messageType + // Resolved before anything is written. A recipient outside the room cannot be + // sent to -- the wrap would be undecryptable by every member including them, + // while the group still saw that a private message had gone somewhere -- and + // the room is also where the relay hint for the `p` tag comes from. + val directMessageRecipient = directMessageRecipientPublicKey?.let { recipient -> + require(recipient != localChatRoom.chatRoom.userPublicKey) { + "Cannot send a direct message to yourself: the wrap's throwaway key means it could never be read back" + } + + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == recipient } + ?: throw IllegalArgumentException( + "Cannot send a direct message to $recipient: not a member of ${localChatRoom.chatRoom.id}" + ) } + // Create ChatRumor... + val createdAt = Clock.System.now().epochSeconds + val marmotInnerEventKind = when { + // A direct message is a NIP-17 chat message that happens to travel inside + // the group, so it keeps kind:14 instead of being mapped down to the + // group's kind:9. That is also what tells the two apart on the way back in. + directMessageRecipient != null -> ChatMessageEvent.KIND + messageType == ChatMessageEvent.KIND -> ChatEvent.KIND // nip-17 chatMessageEvent.kind is in MLS chatMessage.kind + else -> messageType + } + + val marmotInnerEventTags = directMessageRecipient?.let { recipient -> + arrayOf( + PTag.assemble( + recipient.participantPublicKey, + recipient.relayHint?.let { NormalizedRelayUrl(it) } + ) + ) + } ?: emptyArray() + + // For a direct message the queued row IS the rumor the wrap will carry, so + // this id is the one the recipient arrives at after unwrapping -- and, on the + // way out, the one the notary uses to find this chat message again. The wrap + // built around it has its own id, which is why that lookup keys on the row. val marmotInnerEventId = EventHasher.hashId( pubKey = localChatRoom.chatRoom.userPublicKey, createdAt = createdAt, - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, kind = marmotInnerEventKind ) @@ -200,10 +236,11 @@ class DatabaseChatRepository( id = marmotInnerEventId, publicKey = localChatRoom.chatRoom.userPublicKey, createdAt = Instant.fromEpochSeconds(createdAt), - tags = emptyArray(), + tags = marmotInnerEventTags, content = text, chatRoomId = localChatRoom.chatRoom.id, - kind = marmotInnerEventKind + kind = marmotInnerEventKind, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) @@ -216,6 +253,15 @@ class DatabaseChatRepository( giftWrapPayloadId = null, marmotGroupEventId = null, marmotInnerEventId = marmotInnerEventId, + // The sender's only copy: the wrap is sealed with a key that is + // discarded, so nothing can reconstruct these words from the + // transcript later, on this device or any other. + messageType = if (directMessageRecipient != null) { + ChatMessage.TYPE_DIRECT_MESSAGE + } else { + "message" + }, + directMessageRecipientPublicKey = directMessageRecipient?.participantPublicKey ) ) } else { @@ -280,6 +326,32 @@ class DatabaseChatRepository( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync ) { + // An MLS room must never produce a NIP-17 gift wrap of its *contents*. Its messages + // already travel inside kind:445, and its direct messages are real, correctly signed + // NIP-59 wraps -- indistinguishable from something this path would be right to + // publish. The only thing keeping one off a relay is that it never becomes a + // GiftWrapPayload, so refuse here too rather than trusting every future caller to + // know that. See docs/marmot-direct-messages.md. + // + // The Welcome is the one thing an MLS room is *supposed* to publish this way, and + // the reason it reaches this path at all. MIP-02 addresses kind:444 to a joiner who + // holds no group state yet: they cannot read a kind:445, so a relay-borne gift wrap + // is the only way to reach them, and `MarmotOutboundDao.deliveryWelcome` queues one + // here deliberately. Every Welcome carries its room's nostrGroupId, so a refusal + // keyed on `mlsGroupState` alone catches all of them and no invite is ever + // delivered -- and because the unsealed queue is a single-row flow, the refused + // Welcome sits at its head and blocks every payload behind it too. + if (giftWrapPayload.kind != WelcomeEvent.KIND) { + val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId) + if (chatRoom?.chatRoom?.mlsGroupState != null) { + logger.e( + "Refusing to seal payload ${giftWrapPayload.id}: ${giftWrapPayload.chatRoomId} is an MLS room, " + + "and sealing would broadcast it to relays" + ) + return + } + } + database.participantDao().findParticipantsByChatRoomId(giftWrapPayload.chatRoomId).forEach { participant -> if (giftWrapPayload.kind == WelcomeEvent.KIND) { logger.i("Only giftWrap welcomeEvent payload (${giftWrapPayload.id}) to the participant who published the related keyPackage") @@ -407,6 +479,22 @@ class DatabaseChatRepository( onCompletion.invoke() } + /** + * Only the public key is needed: reading a group event is done with the MLS + * group's own secrets, held in the room's stored state, and the identity is + * used for nothing but deciding whether this device is a member and whether a + * line is its own. So a read-only [KeyPair] is enough, and the nostr private + * key stays where it is. + */ + override suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport = + database.nostrDao().reindexMarmotGroupEvents( + chatRoomId = chatRoomId, + activeKeyPair = KeyPair(pubKey = userPublicKey.hexToByteArray()), + ) + companion object { const val TAG = "DatabaseChatRepository" } 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 bd1eeb6f..daacde83 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 @@ -3,9 +3,11 @@ package press.mantra.compose.database.repository import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.repository.DkgRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -48,6 +50,31 @@ class DatabaseDkgRepository( override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = ChillDkgRitualManager.pendingApproval(database, session) + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? { + val thresholdPublicKey = session.thresholdPublicKey ?: return null + + return try { + GroupKeyStateManager.announce( + database = database, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + dkgSessionId = session.id, + thresholdPublicKey = thresholdPublicKey + ) + } catch (e: Throwable) { + // The room not deriving from the key it is about to announce is a + // bug rather than a condition, but it is not worth failing the room + // over: the group still has a working chat, and signing simply falls + // back to the rederivation scan it used before there was a state. + logger.e("Error announcing the key state for $chatRoomId", e) + null + } + } + override suspend fun approve( localChatRoom: LocalChatRoom, sessionId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index eb573bcf..26b4ddf6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -131,28 +131,6 @@ class DatabaseMantraRepository( ) } - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? { - return database.mantraDao().addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = userPublicKey, - visibility = visibility, - license = license - ) - } - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 39581e60..54ec9490 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -518,28 +518,55 @@ class DatabaseNostrRepository( synchronizationRelayURLs: List, activeKeyPair: KeyPair ) { - logger.d("saveNostrEvent: $nostrEvent") + // Under the same lock as the other overloads. storeNostrEvent reads the event, then + // writes it and its indexes; two of those interleaving is a lost update. This path + // used to skip the lock, which was survivable only while a single queue was the one + // thing writing — a live subscription writing alongside a backfill is not that. + storeNostrEventMutex.withLock { + logger.d("saveNostrEvent: $nostrEvent") - try { - database.nostrDao().storeNostrEvent( - nostrEvent, - relayURL = negentropySynchronizeRequest.relayURL, - synchronizationRelayURLs = synchronizationRelayURLs, - level = negentropySynchronizeRequest.level, - activeKeyPair = activeKeyPair - ) - - database.negentropySynchronizeRequestDao().upsert( - negentropySynchronizeRequest.copy( - status = "processed" + try { + database.nostrDao().storeNostrEvent( + nostrEvent, + relayURL = negentropySynchronizeRequest.relayURL, + synchronizationRelayURLs = synchronizationRelayURLs, + level = negentropySynchronizeRequest.level, + activeKeyPair = activeKeyPair ) - ) - } catch (e: Throwable) { - logger.e("Failed to save nostr event $nostrEvent.id", e) + + database.negentropySynchronizeRequestDao().upsert( + negentropySynchronizeRequest.copy( + status = "processed" + ) + ) + } catch (e: Throwable) { + logger.e("Failed to save nostr event ${nostrEvent.id}", e) + } } + } + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) { + storeNostrEventMutex.withLock { + logger.d("saveNostrEvent (live): $nostrEvent") - + try { + database.nostrDao().storeNostrEvent( + nostrEvent, + relayURL = relayURL, + synchronizationRelayURLs = synchronizationRelayURLs, + level = level, + activeKeyPair = activeKeyPair + ) + } catch (e: Throwable) { + logger.e("Failed to save nostr event ${nostrEvent.id}", e) + } + } } override suspend fun queueSynchronizeNostrEvent( 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 2bca8145..09a7f085 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -26,6 +26,7 @@ import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSignerMessage import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage @@ -463,6 +464,19 @@ object FrostSigningManager { ?: return try { + // A signature the group has already made settles this session whether + // or not its owner ever answered: a t-of-n key does not need everybody, + // so a quorum can finish while one member's phone is still in a pocket. + // Ahead of the gate below because that gate is about keeping this + // device's own material off the wire, and finishing puts none of it + // there -- while going on to ask would be asking for a decision that + // can no longer change anything, and would let a late "don't sign" + // abandon a signature that exists. + if (session.signature != null) { + complete(database, session) + return + } + // Nothing of this device's own goes out before its owner has said so. // Returns rather than throws: the session is not failing, it is waiting // on a person, and everything received stays stored so it resumes the @@ -579,43 +593,7 @@ object FrostSigningManager { announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) } - val signature = session.signature ?: return - - // The payoff: a signature that verifies is one the group made, whoever - // relayed it. Checking rather than trusting is what keeps a faulty or - // dishonest coordinator from passing off something that will be - // rejected by every relay it reaches. - val signedEvent = signedEvent(session, signature) - val verified = Nip01Crypto.verify( - signature = signature.hexToByteArray(), - hash = session.eventId.hexToByteArray(), - pubKey = signedEvent.pubKey.hexToByteArray() - ) - if (!verified) { - throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") - } - - update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } - - // A signature exists to be used. Every device has the event and the - // signature by now, so each applies the result itself rather than - // waiting to be sent something it can already build -- the same - // reasoning the transcript lines are written on. Nothing goes on the - // wire: a signed event authored by the threshold key cannot travel as - // an inner event anyway, because the outbound pipeline re-authors - // rumors as their sender and would strip the group's signature off. - applySignedEvent(database, session, signedEvent) - - announce( - database = database, - session = session, - messageType = ChatMessage.TYPE_FROST_COMPLETE, - content = "The group signed the event. It took ${session.threshold} of " + - "${session.participantCount} members.", - actor = session.coordinatorPublicKey - ) - - logger.i("Signing session $sessionId complete") + complete(database, session) } catch (e: CancellationException) { // The sync was torn down mid-step, which says nothing about the session. throw e @@ -632,6 +610,54 @@ object FrostSigningManager { } } + /** + * Verifies the group's signature, uses the event, and closes the session. + * + * The payoff: a signature that verifies is one the group made, whoever + * relayed it. Checking rather than trusting is what keeps a faulty or + * dishonest coordinator from passing off something that will be rejected by + * every relay it reaches. + * + * Reached only with the session still open -- [advance] returns above this + * on a settled one -- so the milestone is written once by construction, + * like every other line in the transcript. + */ + private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) { + val signature = session.signature ?: return + + val signedEvent = signedEvent(session, signature) + val verified = Nip01Crypto.verify( + signature = signature.hexToByteArray(), + hash = session.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") + } + + update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } + + // A signature exists to be used. Every device has the event and the + // signature by now, so each applies the result itself rather than + // waiting to be sent something it can already build -- the same + // reasoning the transcript lines are written on. Nothing goes on the + // wire: a signed event authored by the threshold key cannot travel as + // an inner event anyway, because the outbound pipeline re-authors + // rumors as their sender and would strip the group's signature off. + applySignedEvent(database, session, signedEvent) + + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_COMPLETE, + content = "The group signed the event. It took ${session.threshold} of " + + "${session.participantCount} members.", + actor = session.coordinatorPublicKey + ) + + logger.i("Signing session ${session.id} complete") + } + /** * Turns the signed event into whatever it is: a dialect, an artifact, a * chapter. @@ -789,16 +815,32 @@ object FrostSigningManager { * to be outside of -- while a group event needs an MLS one, so the two * cannot be the same room. * - * They are still bound together, and by construction rather than by a - * column: the #admins room's id *is* the key, derived from it by - * [SharedKeyDerivation.marmotGroupId]. Rederiving is what finds the key - * here, which means a room cannot be pointed at a key it was not derived - * from. + * They are bound together by the room's [GroupKeyState]: the announcement + * the room opened with, naming the ceremony behind it. That is a lookup + * rather than a search, and it carries the derivation path, so a room + * derived anywhere other than [SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH] + * is findable at all -- which the rederivation below cannot manage, since it + * can only rederive at the one path the constant names. * - * Falls back to a ceremony held in this very room, which is not how the app - * wires things today but costs one lookup to keep honest. + * The state buys none of its authority from being written down. It is only + * ever stored having rederived the room it describes, so what actually binds + * a room to a key is still that the room's id *is* the key, and a room still + * cannot be pointed at a key it was not derived from. + * + * Two fallbacks behind it, both for rooms that predate the table: the + * original scan over every ceremony this device holds a share for, and then + * a ceremony held in this very room, which is not how the app wires things + * today but costs one lookup to keep honest. */ suspend fun completedKey(database: MantraDatabase, chatRoomId: String): 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().getKeyHoldingSessions().firstOrNull { session -> session.stage == DkgRitualStage.COMPLETE && session.thresholdPublicKey?.let { @@ -957,6 +999,12 @@ object FrostSigningManager { return false } + // The group signed it without needing this member, and [advance] closes + // the session on its next pass without asking them anything. Offering the + // decision anyway would be offering two bad answers: a nonce nobody is + // waiting for, or a refusal that abandons a signature already made. + if (session.signature != null) return false + return session.signApprovedAt == null } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt new file mode 100644 index 00000000..81ccbbad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -0,0 +1,177 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.MarmotInnerEvent +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * Announces and files what key a room signs with. + * + * The coordinator [announce]s once, as the new room's first message; every + * other member [record]s what arrives. Both ends land on the same + * [GroupKeyState] row, which is what a signing request is resolved against -- + * see `FrostSigningManager.completedKey`. + * + * Nothing here is trusted on the strength of who said it. A state is kept only + * if the room's id rederives from the key it names, which is the same check + * `completedKey` used to make by scanning, and the reason a coordinator cannot + * point a room at a key it was not made from. + */ +object GroupKeyStateManager { + private const val TAG = "GroupKeyStateManager" + + private val logger = Logger.withTag(TAG) + + /** The key state a room signs under, or null while it has none. */ + suspend fun keyStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + database.groupKeyStateDao().getByChatRoomId(chatRoomId) + + /** + * Says what the freshly made room signs with, and files it locally. + * + * Called once, by the member who created the room, before anybody has been + * added to it -- the announcement is the room's first message, so a member + * arriving on a welcome finds it waiting rather than having to be told + * separately. + * + * Queued before it is recorded, matching the signing pipeline: a crash + * between the two costs a duplicate announcement, which [record] folds + * away, rather than a room whose key nobody ever named. + * + * Refuses to announce a state that does not describe the room, because a + * state that fails [GroupKeyState.verifies] here is this device having + * derived the room from one key and announced another -- a bug worth + * failing on rather than broadcasting. + */ + suspend fun announce( + database: MantraDatabase, + chatRoomId: String, + userPublicKey: HexKey, + dkgSessionId: String, + thresholdPublicKey: HexKey, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + createdAt: Long = Clock.System.now().epochSeconds + ): GroupKeyState { + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = userPublicKey, + announcedAt = Instant.fromEpochSeconds(createdAt) + ) + + check(state.verifies()) { + "Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}" + } + + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + path = path + ) + + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + // The rumor id the outbound pipeline will recompute from these + // same fields when it assembles the event to encrypt. + id = EventHasher.hashId( + pubKey = userPublicKey, + createdAt = createdAt, + tags = tags, + content = thresholdPublicKey, + kind = GroupKeyStateEvent.KIND + ), + publicKey = userPublicKey, + kind = GroupKeyStateEvent.KIND, + createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, + content = thresholdPublicKey, + chatRoomId = chatRoomId + ) + ) + + logger.i("Announcing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}") + + return database.groupKeyStateDao().replace(state) + } + + /** + * Files an inbound announcement, or drops it and says why. + * + * Storing is all this adds to [stateFrom], which is where the deciding + * happens -- kept apart so the check a member's safety rests on can be + * exercised without standing up a database. + */ + suspend fun record( + database: MantraDatabase, + chatRoomId: String, + innerEvent: Event + ): GroupKeyState? = + stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) } + + /** + * The state an announcement amounts to, or null if it amounts to none. + * + * Every reason to return null is a reason the announcement does not describe + * this room, and none of them are about who sent it: a member with no share, + * or none of the ceremony at all, can announce a true state and it is still + * true. What cannot be tolerated is a state naming a key the room was not + * derived from, because acting on one means signing with a share that will + * not aggregate -- or, worse, treating a key the group does not hold as the + * key the group holds. + */ + fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) + if (announced != null && announced != chatRoomId) { + logger.w("Key state for room $announced arrived in $chatRoomId; dropping") + return null + } + + val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) + if (thresholdPublicKey == null) { + logger.w("Key state in $chatRoomId carries no threshold key; dropping") + return null + } + + val dkgSessionId = GroupKeyStateEvent.parseDkgSessionId(innerEvent.tags) + if (dkgSessionId == null) { + logger.w("Key state in $chatRoomId names no ceremony; dropping") + return null + } + + val path = GroupKeyStateEvent.parsePath(innerEvent.tags) + if (path == null) { + logger.w("Key state in $chatRoomId carries no walkable derivation path; dropping") + return null + } + + val state = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId, + thresholdPublicKey = thresholdPublicKey, + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = innerEvent.pubKey, + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) + ) + + // The whole trust model, in one line. Anybody may say what this room + // signs with; only the truth rederives the room they said it in. + if (!state.verifies()) { + logger.w( + "Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " + + "${state.derivationPath}, which does not derive room $chatRoomId; dropping" + ) + return null + } + + return state + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt new file mode 100644 index 00000000..7627fcfc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt @@ -0,0 +1,527 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.network.relays.LiveSubscriptionTransport +import press.mantra.compose.network.relays.isRelayBackPressure +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.endsLiveSubscription +import press.mantra.compose.nostr.Nip17Filters +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import kotlin.concurrent.Volatile +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Holds the chat subscriptions open for as long as it runs, so relays push new events at us + * instead of us asking for them once per screen. + * + * See `docs/long-running-sync.md`. The short version: every chat sync in this app used to be + * a pull — a screen queues a request row, a pump drains it, the relay answers, the + * subscription is closed — which is structurally incapable of delivering a message that + * arrives one second after EOSE. + * + * Two subscriptions, on every DM relay: + * - [GIFT_WRAP_SUB_ID], kind 1059 p-tagged to us: direct messages, and the Marmot Welcome + * events that make us a member of a group. + * - [GROUP_SUB_ID_PREFIX]``, kind 445 h-tagged with the groups we belong to, in chunks. + * + * This deliberately does NOT share `SynchronizationViewModel`'s subscription semaphore. That + * budget is four permits across all relays, sized for requests that finish; a subscription + * that never finishes would hold one forever and permanently halve the backfill throughput. + * The two budgets have to be read together against what a relay will actually tolerate + * (commonly ~20 per connection), which they comfortably are. + */ +class LiveSubscriptionManager( + private val relaysSocketManager: LiveSubscriptionTransport, + private val nostrRepository: NostrRepository, + private val chatRepository: ChatRepository, + private val isForeground: StateFlow, +) { + private val logger = Logger.withTag(TAG) + + companion object { + private const val TAG = "LiveSubscriptionManager" + + /** + * Stable, and the same on every relay. A subscription id is scoped to its socket, and + * a relay answers a repeated REQ on an existing id by replacing that subscription's + * filter — which is what lets a filter be widened later without a close-and-reopen. + */ + const val GIFT_WRAP_SUB_ID = "live-giftwrap" + + const val GROUP_SUB_ID_PREFIX = "live-groups-" + + /** + * Group ids per `#h` filter. One subscription per group would be simpler, but every + * Marmot group here lives on the same DM relay set, so carrying many ids in one + * filter costs a fraction of the subscriptions. Conservative rather than maximal: + * relays cap the size of a filter's tag arrays, and a chunk that is refused is a + * chunk of conversations that go quiet. + */ + private const val MAX_GROUPS_PER_SUBSCRIPTION = 100 + + /** + * How much history to ask for when a subscription is opened. + * + * NIP-01 scopes `limit` to the initial query — the stored events a relay sends before + * EOSE — and explicitly not to the stream that follows. So this bounds what a + * reconnect costs without touching the live tail at all. Filling in the rest of the + * history is negentropy's job, not this subscription's. + */ + private const val INITIAL_HISTORY_LIMIT = 100 + + /** + * A group chunk covers up to [MAX_GROUPS_PER_SUBSCRIPTION] conversations at once, so + * the same initial window has to stretch much further than the gift wrap one. + */ + private const val INITIAL_GROUP_HISTORY_LIMIT = 500 + + /** + * Joining a group writes the room, its participants and placeholder profiles in quick + * succession, each of which re-emits the room list. Without this the group filter + * would be re-sent several times per join. + */ + private val GROUP_CHANGE_DEBOUNCE = 500.milliseconds + + private val INITIAL_REOPEN_DELAY = 5.seconds + + private val MAX_REOPEN_DELAY = 5.minutes + +/** + * Every gift wrap addressed to us: direct messages, and the Marmot Welcome events + * that make us a member of a group. + * + * Deferred to [Nip17Filters] rather than written here, because this shape has been + * got wrong in several call sites before and the reasons it is what it is belong in + * one place — chiefly the absence of a `since`, which looks like an oversight and is + * the difference between receiving mail and silently not. + */ + internal fun giftWrapFilter(publicKey: HexKey) = + Nip17Filters.liveInbox(publicKey, limit = INITIAL_HISTORY_LIMIT) + + /** + * Marmot group messages for a chunk of the groups we belong to. + * + * Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them + * with `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not + * used: `limit` already bounds the initial burst, and a watermark would have to be + * recomputed every time the chunk's membership changed. + */ + internal fun groupFilter(groupIds: List) = Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to groupIds), + limit = INITIAL_GROUP_HISTORY_LIMIT, + ) + + /** + * The groups a live subscription should be watching. + * + * An MLS group is a room with group state; a NIP-17 room has none and is served by + * the gift wrap subscription instead. A room we have left or deleted keeps its + * history locally but must stop pulling new messages. + * + * Sorted so the same membership in a different row order is the same value, and a + * re-emit that changes nothing costs nothing downstream. + */ + internal fun groupIdsFrom(rooms: List): List = + rooms + .filter { it.chatRoom.mlsGroupState != null } + .filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null } + .map { it.chatRoom.id } + .sorted() + + internal fun groupChunks(groupIds: List): List> = + groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION) + + internal fun groupSubId(index: Int) = "$GROUP_SUB_ID_PREFIX$index" + + internal fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId" + + /** + * The chunk index a [subscriptionKey] refers to, or [Int.MAX_VALUE] for anything that + * does not parse — which reads as "past the end", so an unrecognised key is closed + * rather than kept open forever. + */ + internal fun subscriptionIndex(key: String) = + key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE + } + + /** + * The group ids each `live-groups-` subscription is currently responsible for. + * + * Read by the subscription coroutines when they (re-)open, written by the single coroutine + * collecting the room list. Replaced wholesale rather than mutated, so a reader always + * sees a coherent chunking. + */ + @Volatile + private var groupIdChunks: List> = emptyList() + + /** + * Runs until cancelled. Cancellation is the only exit: the caller scopes this to the + * active wallet, so a wallet switch tears every subscription down and the new wallet's + * call builds its own. + */ + suspend fun observe(keyPair: KeyPair) { + val publicKey = keyPair.pubKey.toHexKey() + + // collectLatest is the whole lifecycle mechanism: going to the background cancels the + // block below, and every subscription's `finally` sends its CLOSE on the way out. + isForeground.collectLatest { foreground -> + if (!foreground) { + logger.i("Backgrounded; live subscriptions closed") + return@collectLatest + } + + runWhileForeground(publicKey = publicKey, keyPair = keyPair) + } + } + + /** + * Everything below inherits the caller's dispatcher rather than pinning Dispatchers.IO. + * `SynchronizationViewModel` already launches [observe] on IO, so nothing has moved — but + * a coroutine that picks its own dispatcher cannot be driven by a test scheduler. + */ + private suspend fun runWhileForeground(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope { + logger.i("Foregrounded; opening live subscriptions for $publicKey") + + // Before anything is asked for. A socket that was open when the OS suspended us + // reports itself connected on the way back while being functionally dead, and the + // relay dropped our subscriptions long ago. + runCatching { relaysSocketManager.reconnectAll() } + .onFailure { logger.w(throwable = it) { "Reconnect on foreground failed" } } + + // Live subscriptions cover the window we are online for; this covers the gap we were + // not. Neither subsumes the other. + launch { queueCatchUpSynchronization(publicKey) } + + Relays.DefaultDMRelayList.forEach { relay -> + launch { + runLiveSubscription( + subId = GIFT_WRAP_SUB_ID, + relayUrl = relay.url, + filters = { listOf(giftWrapFilter(publicKey)) }, + keyPair = keyPair, + ) + } + } + + launch { followGroupMembership(publicKey = publicKey, keyPair = keyPair) } + } + + /** + * Reconciles what we hold against what the relays hold, for the time we were away. + * + * A live subscription answers "what is new since I connected"; negentropy answers "what do + * you have that I don't". Coming back from the background is exactly the question only the + * second one can answer, and `limit` on the re-opened subscriptions is a window, not a + * guarantee. + */ + private suspend fun queueCatchUpSynchronization(publicKey: HexKey) { + val groupIds = liveGroupIds(publicKey) + + val giftWrapFilter = Nip17Filters.inbox(publicKey) + + val requests = mutableListOf() + + Relays.DefaultDMRelayList.forEach { relay -> + requests += NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = giftWrapFilter, + ), + purpose = "chat", + synchronizationFilter = giftWrapFilter, + relayURL = relay.url, + level = 0, + ) + + if (groupIds.isEmpty()) return@forEach + + val groupSynchronizationFilter = SynchronizationFilter( + kinds = arrayOf(GroupEvent.KIND), + tags = mapOf("h" to groupIds), + ) + requests += NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = groupSynchronizationFilter, + ), + purpose = "mlsMessages", + synchronizationFilter = groupSynchronizationFilter, + relayURL = relay.url, + level = 0, + ) + } + + logger.i("Queueing ${requests.size} catch-up reconciliation(s) over ${groupIds.size} group(s)") + nostrRepository.queueNegentropySynchronizeRequest(requests) + } + + private suspend fun liveGroupIds(publicKey: HexKey): List = + groupIdsFrom(chatRepository.getChatRoomListByPublicKey(publicKey)) + + /** + * Keeps the group subscriptions matching the groups we are actually in. + * + * Derived from the room list rather than wired into the places a group is joined, because + * a group id can appear four ways and only one of them is somewhere anyone would think to + * call a subscribe function: we create a group, we are added to one (a Welcome processed + * deep inside `NostrDao.storeNostrEvent`), membership shifts under us via a commit, or we + * leave. Observing the table catches all four. + * + * It also closes the loop: a Welcome arrives on the gift wrap subscription above, a + * ChatRoom row is written, this flow re-emits, and the group filter widens — so the first + * message in a group we were just added to arrives without anyone opening a chat. + */ + @OptIn(FlowPreview::class) + private suspend fun followGroupMembership(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope { + val subscriptions = mutableMapOf() + + chatRepository.observeChatRoomListByPublicKey(publicKey) + .map { rooms -> groupIdsFrom(rooms) } + .distinctUntilChanged() + .debounce(GROUP_CHANGE_DEBOUNCE) + .collect { groupIds -> + reconcileGroupSubscriptions( + groupIds = groupIds, + subscriptions = subscriptions, + keyPair = keyPair, + scope = this, + ) + } + } + + /** + * Brings the open group subscriptions in line with [groupIds]. + * + * A chunk that already has a subscription is updated in place — the relay replaces that + * subscription's filter and the collector never notices — so a new group does not + * interrupt delivery on the groups that were already being watched. Chunks that no longer + * exist have their coroutine cancelled, which sends the CLOSE from its `finally`. + */ + private suspend fun reconcileGroupSubscriptions( + groupIds: List, + subscriptions: MutableMap, + keyPair: KeyPair, + scope: CoroutineScope, + ) { + val chunks = groupChunks(groupIds) + groupIdChunks = chunks + + logger.i("Live group membership: ${groupIds.size} group(s) over ${chunks.size} subscription(s)") + + Relays.DefaultDMRelayList.forEach { relay -> + val relayUrl = relay.url + + chunks.forEachIndexed { index, chunk -> + val subId = groupSubId(index) + val key = subscriptionKey(relayUrl = relayUrl, subId = subId) + + if (subscriptions[key]?.isActive == true) { + relaysSocketManager.updateLiveSubscription( + reqCommand = ReqCmd(subId = subId, filters = listOf(groupFilter(chunk))), + relayUrl = relayUrl, + ) + } else { + subscriptions[key] = scope.launch { + runLiveSubscription( + subId = subId, + relayUrl = relayUrl, + // Read at every open rather than captured, so a subscription that + // has to be re-opened after a CLOSED comes back with the current + // membership rather than the membership it was created with. + filters = { + groupIdChunks.getOrNull(index) + ?.takeIf { it.isNotEmpty() } + ?.let { listOf(groupFilter(it)) } + .orEmpty() + }, + keyPair = keyPair, + ) + } + } + } + + // Membership shrank far enough to need fewer subscriptions. + subscriptions.keys + .filter { it.startsWith("$relayUrl|$GROUP_SUB_ID_PREFIX") } + .filter { subscriptionIndex(it) >= chunks.size } + .forEach { key -> + logger.i("Closing $key: no groups left in that chunk") + // Joined, not just cancelled. The CLOSE is sent from that coroutine's + // finally, so returning before it lands would let a later reconcile open a + // subscription on the same id that the old one then closes out from under. + subscriptions.remove(key)?.cancelAndJoin() + } + } + } + + /** + * Keeps one subscription open on one relay, re-opening it if the relay ends it. + * + * A socket that merely drops needs nothing from here: the pool replays the REQ on + * reconnect and the collector below, which is attached to the socket client rather than + * to a session, simply starts receiving again. This loop is for the other case — a relay + * that answered with CLOSED, which is terminal for that subscription and needs a new one. + */ + private suspend fun runLiveSubscription( + subId: String, + relayUrl: String, + filters: () -> List, + keyPair: KeyPair, + ) { + var reopenDelay = INITIAL_REOPEN_DELAY + + try { + while (currentCoroutineContext().isActive) { + val currentFilters = filters() + if (currentFilters.isEmpty()) { + // Nothing to ask for right now — a group chunk emptied out and the + // reconcile that will cancel this coroutine has not run yet. + delay(INITIAL_REOPEN_DELAY) + continue + } + + val end = try { + collectUntilClosed( + subId = subId, + relayUrl = relayUrl, + filters = currentFilters, + keyPair = keyPair, + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + logger.e("Live subscription $subId on $relayUrl failed", error) + SubscriptionEnd(reason = error.message, reachedEose = false) + } + + // A subscription that got as far as EOSE was working. Whatever ended it is a + // new problem, not a continuing one, so it does not inherit the old backoff. + if (end.reachedEose) reopenDelay = INITIAL_REOPEN_DELAY + + reopenDelay = if (isRelayBackPressure(end.reason)) { + // Answering "you have too many subscriptions" by opening another one + // promptly is how one refusal becomes a flood of them. + logger.w("$relayUrl is rate limiting $subId; backing off to the maximum") + MAX_REOPEN_DELAY + } else { + minOf(reopenDelay * 2, MAX_REOPEN_DELAY) + } + + logger.w("Re-opening $subId on $relayUrl in $reopenDelay (ended: ${end.reason})") + delay(reopenDelay) + } + } finally { + // NonCancellable so a cancelled manager still releases the retained REQ, which is + // what tells the socket it no longer has a reason to reconnect. + withContext(NonCancellable) { + runCatching { relaysSocketManager.closeLiveSubscription(subId, relayUrl) } + .onFailure { logger.w(throwable = it) { "Failed to close $subId on $relayUrl" } } + } + } + } + + private data class SubscriptionEnd( + /** The relay's CLOSED reason, or the failure that ended the collection. */ + val reason: String?, + /** Whether the relay got as far as sending EOSE before it ended. */ + val reachedEose: Boolean, + ) + + private suspend fun collectUntilClosed( + subId: String, + relayUrl: String, + filters: List, + keyPair: KeyPair, + ): SubscriptionEnd { + var reason: String? = null + var reachedEose = false + + relaysSocketManager.openLiveSubscription( + reqCommand = ReqCmd(subId = subId, filters = filters), + relayUrl = relayUrl, + ).transformWhile { message -> + emit(message) + !message.endsLiveSubscription() + }.collect { message -> + when (message) { + is NostrIncomingMessage.EventMessage -> { + message.nostrEvent?.let { save(it, relayUrl, keyPair) } + } + + is NostrIncomingMessage.EventsMessage -> { + message.nostrEvents.forEach { save(it, relayUrl, keyPair) } + } + + is NostrIncomingMessage.EoseMessage -> { + reachedEose = true + logger.i("$subId on $relayUrl has caught up; now live") + } + + is NostrIncomingMessage.ClosedMessage -> { + reason = message.message + logger.w("Relay closed $subId ($relayUrl): ${message.message}") + } + + is NostrIncomingMessage.NoticeMessage -> { + // Advisory only. A NOTICE carries no subscription id, so it is delivered + // to every collector on the socket — acting on one here would let an + // unrelated relay complaint tear this subscription down. + logger.d("Notice while running $subId ($relayUrl): ${message.message}") + } + + else -> logger.d("Unhandled message on $subId ($relayUrl): $message") + } + } + + return SubscriptionEnd(reason = reason, reachedEose = reachedEose) + } + + /** + * Saved inline rather than in a launched coroutine, on purpose. Sequential writes keep + * the events in the order the relay sent them and let the socket's buffer do the + * back-pressure, instead of fanning a burst out into a coroutine per event. + */ + private suspend fun save(nostrEvent: NostrEvent, relayUrl: String, keyPair: KeyPair) { + nostrRepository.saveNostrEvent( + nostrEvent = nostrEvent, + relayURL = relayUrl, + synchronizationRelayURLs = listOf(relayUrl), + level = 0, + activeKeyPair = keyPair, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt index c7be3dba..159ef231 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotInboundManager.kt @@ -32,10 +32,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.MarmotDirectMessage import press.mantra.compose.nostr.Relays import kotlin.time.Clock @@ -61,6 +63,45 @@ object MarmotInboundManager { private val commitTracker = CommitOrdering.EpochCommitTracker() + /** + * Why an inner application event may not be accepted from this sender, or null. + * + * MIP-03: an inner event's `pubkey` MUST equal the MLS sender's credential identity, + * so a member cannot mint events claiming a different author. + * + * A gift wrap is exempt. Its pubkey is a throwaway key by construction -- see + * [MarmotDirectMessage] -- so there is no author field to compare and the check + * cannot be applied as written. The authorship claim moves inside, to the signed + * kind:13 seal, which [MarmotDirectMessage.classify] binds to this same identity + * before a word is rendered: a verified signature bound to an MLS leaf, rather than a + * plaintext field compared to one. + * + * The exemption is on kind:1059 alone. Widening it to any other kind, or dropping the + * kind guard, hands every member the ability to publish events as anybody. + * + * Other Marmot clients do not have this carve-out and drop these messages as + * impersonation. See docs/marmot-direct-messages.md. + * + * [senderIdentity] is required rather than merely compared: every sender-derived + * field downstream reads from it -- who a message is from, and whether it is ours -- + * because a gift wrap payload carries no author of its own. + */ + fun mip03Rejection( + innerEventKind: Int, + innerEventPubKey: HexKey, + senderIdentity: HexKey?, + ): String? { + if (senderIdentity == null) { + return "No MLS credential identity for the sender leaf" + } + + if (innerEventKind != GiftWrapEvent.KIND && innerEventPubKey != senderIdentity) { + return "MIP-03: inner event pubkey ($innerEventPubKey) does not match MLS sender identity ($senderIdentity)" + } + + return null + } + suspend fun processGroupMembershipChanges( database: MantraDatabase, localChatRoom: LocalChatRoom, @@ -267,11 +308,9 @@ object MarmotInboundManager { if (innerEvent != null) { val senderIdentity = mlsGroup.memberIdentityHex(decrypted.senderLeafIndex) - if (senderIdentity == null || innerEvent.pubKey != senderIdentity) { - return GroupEventResult.Error( - groupId, - "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)", - ) + + mip03Rejection(innerEvent.kind, innerEvent.pubKey, senderIdentity)?.let { reason -> + return GroupEventResult.Error(groupId, reason) } } @@ -413,6 +452,31 @@ object MarmotInboundManager { } } + /** + * Forget the commits being held for [nostrGroupId], so a replay decides its + * epochs again from the events it is about to feed back in. + * + * [handleCommitEvent] holds a commit whenever a second one turns up for an + * epoch, and only [CommitOrdering.EpochCommitTracker.clearEpoch] on a + * successful apply ever empties that. Nothing applies while more than one is + * held, so the set is a dead end: replaying those commits without clearing + * first just adds them again and leaves every one of them pending, and the + * list grows with each attempt. + * + * Safe to drop because the tracker is a scratch pad, not a record -- the + * commits themselves are on disk, and a replay hands them back oldest first, + * which is the order [CommitOrdering.comparator] picks a winner in. + */ + suspend fun forgetPendingCommits(nostrGroupId: HexKey) { + commitTracker + .pendingGroupEpochs() + .filter { it.groupId == nostrGroupId } + .forEach { pending -> + logger.d("Forgetting pending commits for $nostrGroupId epoch ${pending.epoch}") + commitTracker.clearEpoch(pending.groupId, pending.epoch) + } + } + private suspend fun applyCommit( database: MantraDatabase, mlsGroup: MlsGroup, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt new file mode 100644 index 00000000..47071e5d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotReindexSweep.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import press.mantra.compose.database.model.types.MarmotReindexReport + +/** + * Reads a backlog of group events again, repeatedly, until repeating stops helping. + * + * The whole reason a replay is worth anything is that the events depend on each + * other: a commit nobody could apply is what a room's later messages were waiting + * on, and applying it makes them readable. So one pass in order is not enough -- + * a pass that recovers something has changed what the next pass can do. + * + * Ordering is the caller's business; this only decides how many times to go round. + * A pass that recovers nothing ends it, because everything left is waiting on + * something no later pass will produce either. + * + * Kept apart from the database so the stopping rule can be read, and tested, on + * its own -- it is the part with something to get wrong. + */ +object MarmotReindexSweep { + private const val TAG = "MarmotReindexSweep" + + private val logger = Logger.withTag(TAG) + + /** + * How many times a sweep will go round. + * + * A pass that recovers nothing already ends the sweep, so this only bounds the + * pathological shape where every pass recovers exactly one event and the sweep + * turns quadratic. A chain of five events each waiting on the last is already + * far past what relay reordering produces. + */ + const val DEFAULT_MAX_PASSES = 5 + + /** + * @param stored how many group events the room holds in total, for the report. + * @param unresolved those with nothing to show for them, in the order to replay + * them. Anything already read must be left out: a replay is only ever allowed + * to touch events it cannot make worse. + * @param replay reads one event again. Its failures are expected -- a message + * whose key is gone throws every time -- so they are logged, not raised. + * @param isUnresolved asked again after [replay] to see whether it achieved + * anything. This, rather than [replay]'s return, is what counts a recovery: + * an event is recovered when the room has something to show for it, which is + * a fact about the database and not about the call that just ran. + */ + suspend fun run( + stored: Int, + unresolved: List, + maxPasses: Int = DEFAULT_MAX_PASSES, + replay: suspend (T) -> Unit, + isUnresolved: suspend (T) -> Boolean, + ): MarmotReindexReport { + var remaining = unresolved + var recovered = 0 + var pass = 0 + + while (remaining.isNotEmpty() && pass < maxPasses) { + pass++ + val stillUnresolved = mutableListOf() + + for (candidate in remaining) { + try { + replay(candidate) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.w("Replay of $candidate failed", e) + } + + if (isUnresolved(candidate)) { + stillUnresolved.add(candidate) + } else { + recovered++ + } + } + + val madeProgress = stillUnresolved.size < remaining.size + remaining = stillUnresolved + + if (!madeProgress) break + } + + return MarmotReindexReport( + stored = stored, + unresolved = unresolved.size, + recovered = recovered, + failed = remaining.size, + ).also { + logger.i("Swept ${unresolved.size} unresolved event(s) in $pass pass(es): $it") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt index 1a138c00..cde305fe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -115,12 +115,40 @@ object SharedKeyDerivation { ?.firstOrNull { it.trimStart().startsWith(PATH_MARKER) } ?: return null - val path = line.trimStart().removePrefix(PATH_MARKER).trim() + return parsePathString(line.trimStart().removePrefix(PATH_MARKER).trim()) + } + + /** The indices [tweakScalar] can actually tell apart: a BIP32-shaped uint32. */ + private val INDEX_RANGE = 0L..0xFFFFFFFFL + + /** + * A bare `m/9420/0/0` as indices, or null if it is not one. + * + * Split out from [parsePath] because a path also travels on its own, in a + * `GroupKeyStateEvent`'s [press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag], + * where there is no description to dig it out of. Both spellings have to + * agree on what a path is, so there is only one reader of one. + * + * Indices outside a uint32 are rejected, which matters because a path now + * arrives from the wire rather than only from [MARMOT_ADMIN_GROUP_PATH]. + * [tweakScalar] serialises an index as its low four bytes, so without this + * `m/4294967296/0/0` walks to the same key as `m/0/0/0` and a room could be + * described by a path nobody would write. Nothing is stolen by that -- a + * state still has to derive the room it names -- but it would make + * [formatPath] a lossy round trip and leave two spellings of one path for + * any later code to disagree over. Negative indices go the same way: they + * are not a thing a path has. + */ + fun parsePathString(path: String): List? { if (!path.startsWith("m/")) return null return path.removePrefix("m/") .split("/") - .map { segment -> segment.toLongOrNull() ?: return null } + .map { segment -> + val index = segment.toLongOrNull() ?: return null + if (index !in INDEX_RANGE) return null + index + } .takeIf { it.isNotEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt new file mode 100644 index 00000000..a27defe2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/LiveSubscriptionTransport.kt @@ -0,0 +1,28 @@ +package press.mantra.compose.network.relays + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.network.sockets.NostrIncomingMessage + +/** + * The slice of the relay layer a subscription that stays open needs. + * + * Narrower than [RelaysSocketManager] on purpose. That class observes the active wallet and + * starts collecting relay lists in its `init`, so it cannot be stood up in a test at all — + * which would otherwise leave the reconcile loop that keeps group subscriptions matching group + * membership with no coverage but the pure helpers underneath it. + */ +interface LiveSubscriptionTransport { + + /** @see RelayPool.openLiveSubscription */ + suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow + + /** @see RelayPool.updateLiveSubscription */ + suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) + + /** @see RelayPool.closeLiveSubscription */ + suspend fun closeLiveSubscription(subId: String, relayUrl: String) + + /** @see RelayPool.reconnectAll */ + suspend fun reconnectAll() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt new file mode 100644 index 00000000..54c0ae96 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayBackPressure.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.network.relays + +/** + * Whether a relay's CLOSED/NOTICE reason means "you are asking for too much" rather than + * "I cannot serve this". + * + * The difference decides what to do next, and getting it wrong is expensive in both + * directions: answering back-pressure by immediately opening another subscription is what + * turns one refusal into a flood of them, while treating an unsupported filter as + * back-pressure leaves a request unserved for minutes. + * + * NIP-01 gives `rate-limited:` as the machine-readable prefix; the free-text forms are what + * relays actually send. + */ +fun isRelayBackPressure(reason: String?): Boolean { + val text = reason?.lowercase() ?: return false + + return text.startsWith("rate-limited") || + text.contains("rate limit") || + text.contains("too many") || + text.contains("concurrent") || + text.contains("slow down") +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index 0d0b0ad0..a260a1d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -42,6 +42,12 @@ import kotlin.time.Duration.Companion.milliseconds class RelayPool( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, + /** + * Where this pool's own fire-and-forget work runs: status updates, the socket closes that + * a relay-list change triggers, and the subscription replay a reconnect triggers. + * Injectable so a test can drive that work deterministically rather than racing it. + */ + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) { val logger = Logger.withTag("RelayPool") @@ -49,12 +55,24 @@ class RelayPool( const val PUBLISH_TIMEOUT = 30_000 } - private val scope = CoroutineScope(Dispatchers.IO) - val relays: MutableSet = mutableSetOf() private val relayMutex = Mutex() + /** + * The REQ text of every subscription still believed to be open, keyed by normalized + * relay url and then by subscription id, so a socket that comes back can be handed + * its subscriptions again. + * + * Only plain REQs live here. A negentropy exchange is stateful — NEG-OPEN carries a + * fingerprint of the local set and each round depends on the last — so replaying one + * mid-exchange would reconcile against a conversation the relay is no longer having. + * An interrupted negentropy request is abandoned and re-queued instead. + */ + private val retainedRequests = mutableMapOf>() + + private val retainedRequestsMutex = Mutex() + @VisibleForTesting var socketClients = setOf() @@ -75,6 +93,10 @@ class RelayPool( updateRelayStatus(url = url, connected = false) } + private val onSocketConnectionReopenedCallback: press.mantra.compose.network.sockets.SocketConnectionReopenedCallback = { url -> + scope.launch { replayRetainedRequests(url) } + } + fun changeRelays(relays: List) { val existingRelayUrls = socketClients.map { it.socketUrl } val newRelayUrls = relays.map { it.url } @@ -93,6 +115,7 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl }) this.relays.clear() this.relays.addAll(relays) } @@ -111,6 +134,7 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl }) this.relays.removeAll(relays) } @@ -139,24 +163,126 @@ class RelayPool( updateRelayStatus(url = client.socketUrl, connected = false) scope.launch { client.close() } } + forgetRetainedRequests(socketClients.map { it.socketUrl }) socketClients = emptySet() relays.clear() } fun hasRelays() = relays.isNotEmpty() + /** + * Tears every socket down and immediately builds it again. + * + * For coming back from the background, where trusting the connection is the mistake: a + * socket that was open when the OS suspended the process reports itself connected on the + * way back while being functionally dead, and the relay has long since dropped the + * subscriptions it was carrying. + * + * Re-opening here rather than leaving it to the next send is deliberate — it is what makes + * this a RE-connection, so the retained subscriptions are replayed and any collector still + * attached from before the gap starts receiving again. + */ + suspend fun reconnectAll() { + socketClients.forEach { client -> + updateRelayStatus(url = client.socketUrl, connected = false) + runCatching { client.close() } + runCatching { client.ensureSocketConnectionOrThrow() } + .onFailure { logger.w(throwable = it) { "Could not re-open ${client.socketUrl}" } } + } + } + suspend fun tryConnectingToRelay(url: String) { runCatching { socketClients.find { it.socketUrl == url }?.ensureSocketConnectionOrThrow() } } + private fun socketClientFor(relayUrl: String): press.mantra.compose.network.sockets.NostrSocketClient? { + val wanted = NormalizedRelayUrl(relayUrl).displayUrl() + + return socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == wanted } + } + + /** + * Remembers a subscription so it can be re-sent if this relay's socket comes back, + * and tells the socket it is now worth reconnecting. + */ + private suspend fun retainRequest(relayUrl: String, subId: String, message: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + + retainedRequestsMutex.withLock { + retainedRequests.getOrPut(key) { mutableMapOf() }[subId] = message + } + socketClientFor(relayUrl)?.autoReconnect = true + } + + /** + * Forgets a subscription that has been closed. Once a relay is carrying none of ours, + * its socket stops reconnecting on its own: keeping a socket alive for a relay we have + * nothing open on is battery spent on nothing. + */ + private suspend fun releaseRequest(relayUrl: String, subId: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + + val stillWanted = retainedRequestsMutex.withLock { + val forRelay = retainedRequests[key] + forRelay?.remove(subId) + if (forRelay.isNullOrEmpty()) { + retainedRequests.remove(key) + false + } else { + true + } + } + + if (!stillWanted) socketClientFor(relayUrl)?.autoReconnect = false + } + + private fun forgetRetainedRequests(relayUrls: List) { + if (relayUrls.isEmpty()) return + + val keys = relayUrls.map { NormalizedRelayUrl(it).displayUrl() } + scope.launch { + retainedRequestsMutex.withLock { + keys.forEach { retainedRequests.remove(it) } + } + } + } + + /** + * Hands a relay back the subscriptions it was serving before its socket dropped. + * + * Only ever called for a RE-connection. A relay answers a repeated REQ on the same + * subscription id by replacing the filter, so this is a send rather than a + * close-and-reopen, and the collector already attached to the socket's message flow + * simply starts receiving again. + */ + private suspend fun replayRetainedRequests(relayUrl: String) { + val key = NormalizedRelayUrl(relayUrl).displayUrl() + val messages = retainedRequestsMutex.withLock { retainedRequests[key]?.toMap() } + + if (messages.isNullOrEmpty()) return + + val nostrSocketClient = socketClientFor(relayUrl) + if (nostrSocketClient == null) { + logger.w("Cannot replay ${messages.size} subscription(s): $relayUrl has no socket") + return + } + + logger.i("Replaying ${messages.size} subscription(s) on $relayUrl: ${messages.keys}") + messages.forEach { (subId, message) -> + runCatching { nostrSocketClient.sendMESSAGE(message) } + .onFailure { logger.w(throwable = it) { "Failed to replay $subId on $relayUrl" } } + } + } + private fun List.mapAsNostrSocketClient() = this.map { nostrSocketClientFactory.create( wssUrl = it.url, onSocketConnectionOpened = onSocketConnectionOpenedCallback, onSocketConnectionClosed = onSocketConnectionClosedCallback, + onSocketConnectionReopened = onSocketConnectionReopenedCallback, ) } @@ -186,13 +312,19 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val filterRequest = OptimizedJsonMapper.toJson(reqCommand) if (nostrSocketClient == null) { throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") } + + // Retained BEFORE the send, so a socket that dies between here and the relay's + // first answer still gets the REQ replayed when it comes back. Released by + // closeQuery, which every pump calls from a NonCancellable finally. + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + return coroutineScope { val eventFlow = nostrSocketClient.queryAsFlow(reqCommand.subId) with(nostrSocketClient) { @@ -202,6 +334,74 @@ class RelayPool( } } + /** + * Opens a subscription that is meant to stay open, and returns a flow that never + * completes on its own. + * + * The difference from [query] is the absence of `completeOnSubscriptionEnd`. For a + * one-shot request EOSE is the end — it is what finishes the collector, frees the + * subscription slot and triggers the CLOSE. For a live subscription EOSE is only the + * boundary between the stored history and the live tail, and the relay owes us nothing + * further to mark the end. The flow therefore ends when, and only when, the caller + * stops collecting it. + * + * The returned flow is attached to the socket CLIENT's message flow, not to a session, + * so it survives a drop: the socket reconnects, [replayRetainedRequests] re-sends this + * REQ, and the same collector starts receiving again without anything being rebuilt. + */ + suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { + addRelaysIfMissing( + setOf( + NormalizedRelayUrl(relayUrl).url.toRelayDTO() + ) + ) + + val nostrSocketClient = socketClientFor(relayUrl) + ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") + + val filterRequest = OptimizedJsonMapper.toJson(reqCommand) + + // Attached before the send: the socket's message flow is buffered but not replayed, + // so a collector subscribing after the first events arrived would miss them. + val eventFlow = nostrSocketClient.incomingMessages.filterBySubscriptionId(id = reqCommand.subId) + + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + nostrSocketClient.sendMESSAGE(filterRequest) + + return eventFlow + } + + /** + * Narrows or widens a live subscription in place. + * + * A relay answers a repeated REQ on an existing subscription id by replacing that + * subscription's filter, so this never closes anything: the collector opened by + * [openLiveSubscription] keeps running and simply starts matching the new filter. + * Re-retained too, so a reconnect replays the new filter rather than the old one. + */ + suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + val filterRequest = OptimizedJsonMapper.toJson(reqCommand) + + // Retained first, and unconditionally. Recording the new filter is what matters: if + // the send below cannot happen the reconnect replays this, whereas a throw here would + // leave the OLD filter retained and a reconnect would restore a subscription the + // caller has already moved on from. + retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest) + + val nostrSocketClient = socketClientFor(relayUrl) + if (nostrSocketClient == null) { + logger.w("No socket for $relayUrl; ${reqCommand.subId} will be sent when one opens") + return + } + + runCatching { nostrSocketClient.sendMESSAGE(filterRequest) } + .onFailure { logger.w(throwable = it) { "Failed to update ${reqCommand.subId} on $relayUrl; retained for reconnect" } } + } + + /** Ends a live subscription: forgets the retained REQ and tells the relay to stop. */ + suspend fun closeLiveSubscription(subId: String, relayUrl: String) = + closeQuery(CloseCmd(subId = subId), relayUrl) + suspend fun negentropySync(negOpenCmd: NegOpenCmd, relayUrl: String): Flow { addRelaysIfMissing( setOf( @@ -210,7 +410,7 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val negentropySyncRequest = OptimizedJsonMapper.toJson(negOpenCmd) @@ -232,20 +432,22 @@ class RelayPool( * [negentropySync] rather than opening a new one. */ suspend fun sendNegentropyMessage(negMsgCmd: NegMsgCmd, relayUrl: String) { - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) ?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected") nostrSocketClient.sendMESSAGE(OptimizedJsonMapper.toJson(negMsgCmd)) } suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) { + releaseRequest(relayUrl = relayUrl, subId = closeCmd.subId) + addRelaysIfMissing( setOf( NormalizedRelayUrl(relayUrl).url.toRelayDTO() ) ) - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val closeSubscription = OptimizedJsonMapper.toJson(closeCmd) @@ -267,7 +469,7 @@ class RelayPool( ) logger.d("socketClients: ${socketClients.map { it.socketUrl }}") - val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() } + val nostrSocketClient = socketClientFor(relayUrl) val closeNegentropySubscription = OptimizedJsonMapper.toJson(negCloseCmd) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index fe9bc561..d901402a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -33,7 +33,7 @@ class RelaysSocketManager( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, -) { +) : LiveSubscriptionTransport { val logger = Logger.withTag("RelaysSocketManager") private val scope = CoroutineScope(Dispatchers.IO) private val relayPoolsMutex = Mutex() @@ -126,6 +126,8 @@ class RelaysSocketManager( ) } + override suspend fun reconnectAll() = relayPool.reconnectAll() + fun tryConnectingToAllRelays() { relayPool.relays.forEach { scope.launch { @@ -144,6 +146,27 @@ class RelaysSocketManager( } + override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow { + return relayPool.openLiveSubscription( + reqCommand = reqCommand, + relayUrl = relayUrl + ) + } + + override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + return relayPool.updateLiveSubscription( + reqCommand = reqCommand, + relayUrl = relayUrl + ) + } + + override suspend fun closeLiveSubscription(subId: String, relayUrl: String) { + return relayPool.closeLiveSubscription( + subId = subId, + relayUrl = relayUrl + ) + } + suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) { return relayPool.closeQuery( closeCmd = closeCmd, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt index 153a6d4e..d876c11b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessageExt.kt @@ -28,6 +28,19 @@ fun NostrIncomingMessage.isTerminalFor(id: String): Boolean = (this is NostrIncomingMessage.ClosedMessage && subscriptionId == id) || (this is NostrIncomingMessage.NegentropyError && subscriptionId == id) +/** + * True for the message that ends a subscription meant to stay OPEN. + * + * Only CLOSED. EOSE emphatically does not end one: it is the boundary between the stored + * history a relay had and the live tail it will now stream, and treating it as an end is + * exactly what makes a subscription a poll. Compare [isTerminalFor], which is the rule for a + * one-shot request, where EOSE is the whole point. + * + * No subscription-id argument, unlike [isTerminalFor]: a live collector is already filtered by + * id before it gets here. + */ +fun NostrIncomingMessage.endsLiveSubscription(): Boolean = this is NostrIncomingMessage.ClosedMessage + /** * Completes the flow once the subscription is over, emitting the terminal * message first so callers still see the EOSE/CLOSED/NEG-ERR that ended it. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt index 222a6a04..44105ce7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt @@ -9,6 +9,15 @@ interface NostrSocketClient { val incomingMessages: SharedFlow + /** + * Whether a socket that drops should be re-opened on its own, with backoff. + * + * Off by default, and owned by the pool rather than the socket: reconnecting a + * socket nobody is subscribed on is pure battery cost, so the pool turns this on + * for exactly as long as it is retaining at least one subscription for the relay. + */ + var autoReconnect: Boolean + suspend fun close() @Throws( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt index 735d543b..99262b0d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt @@ -19,7 +19,25 @@ internal val defaultSocketsHttpClient by lazy { } -object NostrSocketClientFactory { +/** + * How [press.mantra.compose.network.relays.RelayPool] gets a socket for a relay. + * + * An interface rather than the object directly, so the pool's subscription bookkeeping — what + * it retains, when it replays, when it lets a socket stop reconnecting — can be exercised + * against a fake instead of against a real websocket. + */ +interface NostrSocketClientFactory { + + fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean = false, + onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, + onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, + ): NostrSocketClient +} + +object DefaultNostrSocketClientFactory : NostrSocketClientFactory { fun create( wssUrl: String, @@ -27,6 +45,7 @@ object NostrSocketClientFactory { incomingCompressionEnabled: Boolean = false, onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, ): NostrSocketClient { return NostrSocketClientImpl( httpClient = httpClient, @@ -34,19 +53,22 @@ object NostrSocketClientFactory { incomingCompressionEnabled = incomingCompressionEnabled, onSocketConnectionOpened = onSocketConnectionOpened, onSocketConnectionClosed = onSocketConnectionClosed, + onSocketConnectionReopened = onSocketConnectionReopened, ) } - fun create( + override fun create( wssUrl: String, - incomingCompressionEnabled: Boolean = false, - onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, - onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, ) = create( httpClient = defaultSocketsHttpClient, wssUrl = wssUrl, incomingCompressionEnabled = incomingCompressionEnabled, onSocketConnectionOpened = onSocketConnectionOpened, onSocketConnectionClosed = onSocketConnectionClosed, + onSocketConnectionReopened = onSocketConnectionReopened, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt index e5c8235e..2a6fb364 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,6 +22,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import okio.Buffer import okio.GzipSink @@ -28,8 +30,9 @@ import okio.Inflater import okio.InflaterSource import okio.buffer import okio.use +import kotlin.concurrent.Volatile import kotlin.coroutines.cancellation.CancellationException -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -40,36 +43,92 @@ internal class NostrSocketClientImpl( private val incomingCompressionEnabled: Boolean = false, private val onSocketConnectionOpened: SocketConnectionOpenedCallback? = null, private val onSocketConnectionClosed: SocketConnectionClosedCallback? = null, + private val onSocketConnectionReopened: SocketConnectionReopenedCallback? = null, ) : NostrSocketClient { val logger = Logger.withTag("NostrSocketClientImpl") + companion object { + /** Wait before the first reconnect attempt; doubles per failure up to [MAX_RECONNECT_DELAY]. */ + private val INITIAL_RECONNECT_DELAY = 1.seconds + + private val MAX_RECONNECT_DELAY = 60.seconds + + /** Fraction of the delay added at random. See [reconnectDelay]. */ + private const val RECONNECT_JITTER = 0.25 + + /** + * How far a collector may lag before its socket's reader is made to wait for it. + * Deep enough to absorb the burst a relay sends between a REQ and its EOSE while a + * collector writes each event to SQLite; not so deep that a genuinely stuck + * collector is invisible. + */ + private const val INBOUND_BUFFER = 256 + } + + /** Outcome of a connect attempt, so the caller can tell a first connect from a repair. */ + private enum class ConnectOutcome { ALREADY_CONNECTED, OPENED, REOPENED } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val wsMutex = Mutex() private var wsSession: WebSocketSession? = null private var wsReceiverJob: Job? = null + private var reconnectJob: Job? = null - private val _incomingMessages = MutableSharedFlow() + /** Consecutive failed reconnect attempts; reset the moment a session is established. */ + private var reconnectAttempts = 0 + + /** + * True once a session has been established, so the next one is a RE-connection and + * the pool owes it a subscription replay. + */ + private var hasConnected = false + + /** + * Set by [close], cleared by the next caller-driven connect. A socket the client + * deliberately closed must stay closed — otherwise `closePool` would be answered by + * every socket in it politely reconnecting. + */ + @Volatile + private var closedByClient = false + + @Volatile + override var autoReconnect: Boolean = false + + /** + * Buffered on purpose. This used to be a rendezvous flow emitted into from a coroutine + * launched per message, which had two consequences a short-lived request/response + * collector never noticed and a permanent one would: messages reached collectors in + * whatever order those coroutines were scheduled, and an emit with every collector busy + * blocked on the slowest of them. + * + * With a buffer, a collector may fall [INBOUND_BUFFER] messages behind before it slows + * the socket reader down, and the reader emits in wire order. + */ + private val _incomingMessages = MutableSharedFlow( + extraBufferCapacity = INBOUND_BUFFER, + ) override val incomingMessages = _incomingMessages.asSharedFlow() override val socketUrl = wssUrl.cleanWebSocketUrl() override suspend fun ensureSocketConnectionOrThrow() { - if (wsSession != null && wsSession?.isActive == true) return + if (isSessionActive()) return - wsMutex.withLock { - if (wsSession == null || wsSession?.isActive == false) { - wsSession = acquireWebSocketSession(socketUrl) - } - } - } + val outcome = wsMutex.withLock { + // Asking for the socket is asking for it to stay up: clear the flag close() + // set so a drop after this point reconnects again. + closedByClient = false - private suspend fun acquireWebSocketSession(url: String): WebSocketSession { - return try { - httpClient.webSocketSession(urlString = url).apply { - launchWebSocketReceiver() - onSocketConnectionOpened?.invoke(url) + if (isSessionActive()) { + ConnectOutcome.ALREADY_CONNECTED + } else { + wsSession = openSession() + // Assigned BEFORE the post-connect handshake below, because sendMESSAGE + // writes to `wsSession` rather than to the session it was handed — the + // compression REQ used to go to the previous (usually null) session and + // was silently dropped. if (incomingCompressionEnabled) { val id = Uuid.generateV4().toHexDashString() sendMESSAGE( @@ -77,11 +136,35 @@ internal class NostrSocketClientImpl( ensureSessionBeforeSend = false, ) } + + val reopened = hasConnected + hasConnected = true + reconnectAttempts = 0 + if (reopened) ConnectOutcome.REOPENED else ConnectOutcome.OPENED } + } + + if (outcome == ConnectOutcome.ALREADY_CONNECTED) return + + // Fired outside the lock. A callback that turns around and sends on this socket + // would otherwise queue behind a mutex the caller still holds. + onSocketConnectionOpened?.invoke(socketUrl) + if (outcome == ConnectOutcome.REOPENED) onSocketConnectionReopened?.invoke(socketUrl) + } + + private fun isSessionActive() = wsSession?.isActive == true + + /** Opens a session and starts its receiver. Call with [wsMutex] held. */ + private suspend fun openSession(): WebSocketSession { + return try { + httpClient.webSocketSession(urlString = socketUrl).apply { launchWebSocketReceiver() } } catch (error: Exception) { - logger.w("NostrSocketClient::acquireWebSocketSession($socketUrl) failed.", error) - close() + logger.w("NostrSocketClient::openSession($socketUrl) failed.", error) + wsReceiverJob?.cancel() + wsReceiverJob = null + wsSession = null onSocketConnectionClosed?.invoke(socketUrl, error) + scheduleReconnect() throw press.mantra.compose.exceptions.NetworkException(cause = error) } } @@ -94,26 +177,28 @@ internal class NostrSocketClientImpl( } private suspend fun WebSocketSession.receiveSocketMessages() { + var failure: Throwable? = null try { for (frame in incoming) { when (frame) { is Frame.Text -> { val text = frame.readText() logLargeText(text = text, url = socketUrl, incoming = true) - processIncomingMessage(text = text) + emitIncomingMessage(text = text) } is Frame.Binary -> { val decompressedMessage = decompressMessage(frame.data) logLargeText(text = decompressedMessage, url = socketUrl, incoming = true) - processIncomingMessage(text = decompressedMessage) + emitIncomingMessage(text = decompressedMessage) } is Frame.Close -> { val closeReason = frame.readReason() logger.w { "WS $socketUrl closed. [${closeReason?.code}, ${closeReason?.message}]" } - close() - onSocketConnectionClosed?.invoke(socketUrl, null) + // Leave the teardown to onSessionLost below. The relay closing us + // is not the client closing us, so this still earns a reconnect. + break } else -> Unit @@ -124,34 +209,110 @@ internal class NostrSocketClientImpl( throw error } catch (error: Exception) { logger.w("NostrSocketClient::receiveSocketMessages() on $socketUrl failed.", error) - close() + failure = error + } + + onSessionLost(session = this, error = failure) + } + + /** + * The single exit point for a session that ended without the client asking. Clears the + * session, tells the pool, and arms the reconnect. + * + * NonCancellable because the receiver job is cancelled as part of a replacement connect, + * and a cancelled cleanup would leave `wsSession` pointing at a dead socket. + */ + private suspend fun onSessionLost(session: WebSocketSession, error: Throwable?) = + withContext(NonCancellable) { + val wasCurrent = wsMutex.withLock { + // Identity check, not a null check: a reconnect may already have installed a + // newer session, and clearing that one would drop a healthy socket. + if (wsSession !== session) { + false + } else { + wsSession = null + true + } + } + + if (!wasCurrent) return@withContext + + runCatching { + session.close( + reason = CloseReason( + code = CloseReason.Codes.NORMAL, + message = "Session ended.", + ), + ) + } onSocketConnectionClosed?.invoke(socketUrl, error) + scheduleReconnect() + } + + /** + * Re-opens a dropped socket with exponential backoff, for as long as the pool says it + * still wants one. Nothing in this app used to reconnect at all: a socket that failed + * stayed failed, and the only reason that was survivable is that every subscription was + * short and the next queued request opened a fresh socket on its way out. + */ + private fun scheduleReconnect() { + if (!autoReconnect || closedByClient) return + if (reconnectJob?.isActive == true) return + + reconnectJob = scope.launch { + while (isActive && autoReconnect && !closedByClient) { + val attempt = ++reconnectAttempts + val wait = reconnectDelay( + attempt = attempt, + initialDelay = INITIAL_RECONNECT_DELAY, + maxDelay = MAX_RECONNECT_DELAY, + jitterFraction = RECONNECT_JITTER, + ) + logger.i { "Reconnecting to $socketUrl in $wait (attempt $attempt)" } + delay(wait) + + if (!autoReconnect || closedByClient) return@launch + + val reconnected = runCatching { ensureSocketConnectionOrThrow() } + if (reconnected.isSuccess) { + logger.i { "Reconnected to $socketUrl after $attempt attempt(s)" } + return@launch + } + } } } override suspend fun close() { - wsReceiverJob?.cancel() - wsReceiverJob = null + val session = wsMutex.withLock { + closedByClient = true + val current = wsSession + wsSession = null + wsReceiverJob?.cancel() + wsReceiverJob = null + current + } + + reconnectJob?.cancel() + reconnectJob = null + runCatching { - wsSession?.close( + session?.close( reason = CloseReason( code = CloseReason.Codes.NORMAL, message = "Closed by client.", ), ) } - wsSession = null } - private fun processIncomingMessage(text: String) { - text.parseIncomingMessage()?.let { - scope.launch { - if (it is NostrIncomingMessage.EoseMessage) { - delay(75.milliseconds) - } - _incomingMessages.emit(value = it) - } - } + /** + * Emits inline, on the reader, so messages reach collectors in the order the relay sent + * them. That replaces a 75ms delay this used to sleep before every EOSE — a way of + * hoping the events that preceded it had already been delivered by their own coroutines. + * Ordering is now a property rather than a race that usually resolved in time. + */ + private suspend fun emitIncomingMessage(text: String) { + text.parseIncomingMessage()?.let { _incomingMessages.emit(value = it) } } override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) { @@ -159,7 +320,14 @@ internal class NostrSocketClientImpl( ensureSocketConnectionOrThrow() } logLargeText(text = text, url = socketUrl, incoming = false) - wsSession?.send(Frame.Text(text = text)) + val session = wsSession + if (session == null) { + // Used to be a silent `?.` no-op, which is how a dropped socket could swallow + // a send and leave the caller waiting on an answer to a message never sent. + logger.w { "Dropping a send to $socketUrl: no session" } + return + } + session.send(Frame.Text(text = text)) } override suspend fun sendREQ(subscriptionId: String, data: JsonObject) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt new file mode 100644 index 00000000..7adc6ceb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/ReconnectBackoff.kt @@ -0,0 +1,37 @@ +package press.mantra.compose.network.sockets + +import kotlin.math.min +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration + +/** + * Bounds the exponent so a socket that has been failing for hours cannot overflow the doubling + * into `Infinity`, which `Duration * Double` rejects outright — turning a reconnect loop into a + * crash loop at roughly the point the network is least likely to come back on its own. + */ +internal const val MAX_RECONNECT_EXPONENT = 16 + +/** + * How long to wait before reconnect [attempt]: [initialDelay] doubled once per previous + * failure, capped at [maxDelay], plus up to [jitterFraction] of that again at random. + * + * The jitter is not decoration. Every relay in the pool drops at the same moment when the + * network does, so without it they all come back in lockstep for as long as the outage lasts, + * and each round of that is a simultaneous burst of connection attempts. + * + * [jitter] is a parameter rather than an inline `Random.nextDouble()` so the arithmetic can be + * pinned in a test; callers leave it defaulted. + */ +internal fun reconnectDelay( + attempt: Int, + initialDelay: Duration, + maxDelay: Duration, + jitterFraction: Double, + jitter: Double = Random.nextDouble(), +): Duration { + val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT).coerceAtLeast(0)) + val backoff = minOf(initialDelay * doublings, maxDelay) + + return backoff + backoff * jitterFraction * jitter +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt index 580da97b..81f778a4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/SocketConnectionCallbacks.kt @@ -2,3 +2,13 @@ package press.mantra.compose.network.sockets typealias SocketConnectionOpenedCallback = (url: String) -> Unit typealias SocketConnectionClosedCallback = (url: String, error: Throwable?) -> Unit + +/** + * Fired when a session is established on a socket that had already been connected + * once before — i.e. after a drop, not on the first connect. + * + * Deliberately separate from [SocketConnectionOpenedCallback]. The pool replays the + * subscriptions a socket was carrying when it comes back, and on a FIRST connect + * that replay would double-send the very REQ whose `sendMESSAGE` opened the socket. + */ +typealias SocketConnectionReopenedCallback = (url: String) -> Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt new file mode 100644 index 00000000..c41d8a76 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDelivery.kt @@ -0,0 +1,60 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import press.mantra.compose.database.model.BroadcastNostrEventRequest +import press.mantra.compose.database.model.ChatMessage + +/** + * What has to be written once a queued inner event has been encrypted into a + * group event: where it is sent, and which transcript row -- if any -- the send + * belongs to. + * + * The two are independent, and this exists to say so. A group event is sent + * because it was queued; a chat line is linked because a member said something. + * Letting the second decide the first is what silenced FROST signing: the + * broadcast rows were written inside `chatMessageOrNull?.let { }`, and protocol + * traffic has no ChatMessage -- `ChatMessage.applyInnerEvent` returns null for + * every [press.mantra.compose.nostr.frost.FrostSigningEvents] kind and for + * [press.mantra.compose.nostr.frost.GroupKeyStateEvent], and + * `FrostSigningManager` queues its messages without one. So every signing + * proposal was MLS-encrypted, wrapped, persisted and marked processed, and then + * never sent to a relay. Nothing failed; the other participants simply never saw + * it. + * + * Pulled out of `MarmotOutboundDao.encryptAndSendMarmotInnerEvent` because that + * function is Room-backed and cannot be unit tested, which is precisely how the + * gate survived. The decision is separated from the filing of it for the same + * reason [MarmotDirectMessage.classify] is. + */ +data class MarmotDelivery( + /** + * One request per relay, always. An empty list here means the event is on + * disk and going nowhere. + */ + val broadcasts: List, + /** + * The chat line this send belongs to, or null when the row is protocol + * traffic -- something the room did rather than something a member said. + */ + val transcriptChatMessageId: Long?, +) { + /** True when nothing in the chat points at this event, which is not a reason to withhold it. */ + val isProtocolTraffic: Boolean get() = transcriptChatMessageId == null + + companion object { + fun plan( + groupEventId: HexKey, + relays: Collection, + chatMessage: ChatMessage?, + ): MarmotDelivery = MarmotDelivery( + broadcasts = relays.map { relay -> + BroadcastNostrEventRequest( + nostrEventId = groupEventId, + relayURL = relay.url, + ) + }, + transcriptChatMessageId = chatMessage?.id, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt new file mode 100644 index 00000000..4a7fc810 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MarmotDirectMessage.kt @@ -0,0 +1,248 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * A one-to-one message carried inside a Marmot group, as the application payload of a + * kind:445 group event. See docs/marmot-direct-messages.md. + * + * The payload is a stock NIP-59 gift wrap: a throwaway-keyed kind:1059 around a + * sender-signed kind:13 seal around the kind:14 rumor that holds the words. Every member + * of the group decrypts the MLS layer and sees the wrap; only the recipient can open it. + * + * Everything here is a pure function of its arguments, with no database and no MLS state, + * which is what makes it testable — Room-backed code cannot be unit-tested in this project. + * + * **Nothing built here may ever be broadcast.** It is a genuine, correctly signed NIP-59 + * gift wrap, indistinguishable from one the NIP-17 path would be right to publish, so the + * only thing keeping it off a relay is the tables it is kept out of: a Marmot direct + * message lives in `MarmotInnerEvent`, never in `GiftWrapPayload` or `GiftWrapMessage`. + */ +object MarmotDirectMessage { + /** + * A wrap that opened, both layers of it. + * + * The seal is returned alongside the rumor because it is the only layer that names + * the sender, and the caller has to bind it to the MLS sender identity before it + * believes a word — see [MarmotDirectMessage] and the inbound path. + */ + data class Opened( + val seal: Event, + val rumor: Event, + ) + + /** + * What this device should do with an arriving wrap. + * + * Separated from the filing of it so the decision can be tested: the persistence it + * drives needs a database, which cannot be unit-tested in this project, and the + * decision includes the check that replaces MIP-03 for kind:1059. That check is the + * only thing standing between somebody else's words and being rendered as a member's + * own, so it is not somewhere to rely on integration testing that does not exist. + */ + sealed interface Delivery { + /** + * Our own message coming back, which we cannot open -- the wrap's key was + * discarded at send time -- and so cannot tell apart from [Unreadable] by + * decryption alone. Only the MLS sender identity distinguishes them. + * + * File nothing: the row written on send holds the only copy of these words, and + * an unreadable line would replace them with a placeholder. + */ + data object Ours : Delivery + + /** Somebody else's. The group sees that it happened, and to whom. */ + data object Unreadable : Delivery + + /** Addressed to us, opened, and consistent with the leaf that sent it. */ + data class Readable( + val opened: Opened, + ) : Delivery + + /** Something is wrong with it. Drop it; do not render it as anybody's. */ + data class Rejected( + val reason: String, + ) : Delivery + } + + /** + * Decides what [wrap] is to this device, given the identity MLS says sent it. + * + * [senderIdentity] is not optional and is not read from the payload: a wrap names + * nobody, so the MLS frame is the only source of who sent it. Absent one there is + * nothing to attribute a line to and nothing to check the seal against. + */ + fun classify( + wrap: Event, + me: KeyPair, + senderIdentity: HexKey?, + ): Delivery { + if (senderIdentity == null) { + return Delivery.Rejected("no MLS sender identity to attribute it to") + } + + if (senderIdentity == me.pubKey.toHexKey()) return Delivery.Ours + + val opened = open(wrap, me) ?: return Delivery.Unreadable + + // The seal is the one layer the sender signs. Binding it to the leaf MLS says + // sent this message is what replaces MIP-03's pubkey check for kind:1059 -- + // a verified signature bound to a leaf, rather than a plaintext field compared + // to one. A divergence here is somebody passing off words as another member's. + if (opened.seal.pubKey != senderIdentity) { + return Delivery.Rejected( + "sealed by ${opened.seal.pubKey} but sent by $senderIdentity", + ) + } + + if (!opened.seal.verify()) { + return Delivery.Rejected("the seal's signature does not verify") + } + + if (opened.rumor.pubKey != opened.seal.pubKey) { + return Delivery.Rejected( + "rumor by ${opened.rumor.pubKey} inside a seal by ${opened.seal.pubKey}", + ) + } + + if (opened.rumor.kind != ChatMessageEvent.KIND) { + return Delivery.Rejected("unsupported rumor kind ${opened.rumor.kind}") + } + + return Delivery.Readable(opened) + } + + /** + * Builds the payload for a direct message to [recipientPublicKey]. + * + * [createdAt] is used unfuzzed on all three layers. NIP-59 randomises the wrap and + * the seal by up to two days to frustrate correlation at a relay, and both + * `GiftWrapEvent.create` and `SealedRumorEvent.create` default to that; there is no + * relay at this layer, the kind:445 around it already carries the true time, and + * fuzzing would only scatter the "sent a private message" line up to two days out of + * position in every other member's transcript. + * + * The rumor is assembled from exactly the fields the caller queued, so its id is the + * one the recipient computes after unwrapping — and, on the way out, the one the + * outbound path uses to find the chat message to link and broadcast. + */ + fun wrap( + signer: NostrSignerSync, + recipientPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + recipientRelayHint: NormalizedRelayUrl? = null, + ): GiftWrapEvent { + val rumor = rumor(signer.pubKey, kind, createdAt, tags, content) + + // The only layer that names the sender, and the only one they sign. Everything + // the recipient believes about who wrote this rests on this signature. + // + // Built by hand rather than through SealedRumorEvent.create, which is suspend, + // takes a NostrSigner rather than the sync signer the outbound path holds, and + // defaults createdAt to the two-day fuzz. + val seal = + signer.signNormal( + createdAt = createdAt, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(rumor.toJson(), recipientPublicKey), + ) + + // create() mints and discards its own random key -- "GiftWrap is always a random + // key" -- which is the point: nothing in the wrap names the sender. Who sent it + // comes from the MLS frame instead, which is authenticated and cannot be forged. + return GiftWrapEvent.create( + event = seal, + recipientPubKey = recipientPublicKey, + createdAt = createdAt, + recipientRelayHint = recipientRelayHint, + ) + } + + /** + * The rumor a direct message carries, unsigned and unencrypted. + * + * Exposed so the outbound queue can compute a row id before it has a signer, and so + * tests can assert that the id the recipient arrives at is the id the sender queued. + */ + fun rumor( + senderPublicKey: HexKey, + kind: Kind, + createdAt: Long, + tags: Array>, + content: String, + ): Event = + RumorAssembler.assembleRumor( + pubKey = senderPublicKey, + ev = + EventTemplate( + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ), + ) + + /** + * Opens [wrap] with [me]'s key, or returns null if it was not addressed to them. + * + * Trying is the test: the `p` tag is a routing hint, not the authority, and the wrap + * either decrypts with our key or it does not. Null covers every way that can fail -- + * somebody else's message, a malformed payload, a layer that is not the kind it + * claims -- because the caller is inside inbound processing for a kind:445 that may + * carry a perfectly good message for somebody, and a throw would abandon all of it. + * + * **The sender cannot open their own wrap.** The throwaway key is discarded at send + * time and nothing can reconstruct it, so a sender's own message returns null here + * too. That is a property, not a defect: see docs/marmot-direct-messages.md. Do not + * "fix" it by storing the throwaway private key. + */ + fun open( + wrap: Event, + me: KeyPair, + ): Opened? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val privateKey = me.privKey ?: return null + + val seal = decryptEvent(wrap.content, privateKey, wrap.pubKey) ?: return null + if (seal.kind != SealedRumorEvent.KIND) return null + + val rumor = decryptEvent(seal.content, privateKey, seal.pubKey) ?: return null + + return Opened(seal = seal, rumor = rumor) + } + + private fun decryptEvent( + ciphertext: String, + privateKey: ByteArray, + fromPublicKey: HexKey, + ): Event? = + try { + Event.fromJsonOrNull( + Nip44.decrypt( + payload = ciphertext, + privateKey = privateKey, + pubKey = fromPublicKey.hexToByteArray(), + ), + ) + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt new file mode 100644 index 00000000..4d9a0da4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Nip17Filters.kt @@ -0,0 +1,63 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The one filter shape that can return a NIP-17 message we are able to read. + * + * A gift wrap hides everything except who it is for. The author is the throwaway + * key [GiftWrapEvent.create] mints and discards, the content is sealed to the + * recipient, and `created_at` is randomised up to two days into the past. That + * leaves the `p` tag as the only clause worth writing, and it has to name us: + * naming a peer subscribes to mail no key of ours can open, and adding `authors` + * matches nothing on any relay, ever. Both mistakes were live in three separate + * call sites, so the filter is built in one place now and asserted in one place. + */ +object Nip17Filters { + + /** + * Everything gift-wrapped to [publicKey], capped at [limit] events. + * + * Deliberately carries no `since`. NIP-59 back-dates a wrap by up to two days, + * so a cursor built from the newest wrap we hold silently skips mail that was + * sent later but stamped earlier. + */ + fun inbox( + publicKey: HexKey, + limit: Int = DEFAULT_LIMIT, + ) = SynchronizationFilter( + kinds = arrayOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = limit, + ) + + /** + * The same inbox, shaped for a REQ instead of for a negentropy request. + * + * Two types for one filter is not a duplication worth removing: a + * [SynchronizationFilter] is what the request queue stores and what + * `computeId` hashes, while a live subscription puts a quartz `Filter` + * straight onto the wire. Keeping both here is what stops them drifting — + * which for this filter means one of them quietly stops matching mail. + * + * The default limit is larger because this one is opened rather than queued: + * NIP-01 scopes `limit` to the stored events a relay sends before EOSE and + * explicitly not to the stream after it, so it bounds what a reconnect costs + * without capping anything that arrives live. + */ + fun liveInbox( + publicKey: HexKey, + limit: Int = DEFAULT_LIVE_LIMIT, + ) = Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(publicKey)), + limit = limit, + ) + + const val DEFAULT_LIMIT = 50 + + const val DEFAULT_LIVE_LIMIT = 100 +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index c61a5a71..04b99d1d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -24,6 +24,11 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag * anyone --[ 30325 failure ]-> everyone abandon + blame * ``` * + * [GroupKeyStateEvent] sits just past them on 30326. It is not part of a + * session -- it is the standing fact a session is opened against, saying which + * key the room signs with -- so it is deliberately outside [ALL], which is what + * the inbound path dispatches a session message on. + * * ### Why 3032x * * These share the inner-event space with the nip30303 document kinds, which run diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt new file mode 100644 index 00000000..a6a59b73 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -0,0 +1,108 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag +import press.mantra.compose.nostr.frost.tags.FrostKeyTag + +/** + * What key a Marmot room signs with, announced into the room itself. + * + * The coordinator posts one as the room's first message, right after creating + * it. Content is the group's ChillDKG threshold public key; the tags name the + * ceremony that produced it and the path the room's id was derived at. + * + * ``` + * coordinator --[ 30326 group key state ]-> everyone "this room signs with K, at m/9420/0/0" + * ``` + * + * ### What it is for + * + * A signer holds a different secret share under every ceremony it took part in, + * and signing with the wrong one produces a partial signature that cannot + * aggregate. This is the record that says which. A member reads the state, + * follows [FrostKeyTag] to the `DkgSession` row their own device already holds, + * and takes the share from there -- the association travels, the share does not. + * + * Nothing secret is in here, and that is not an accident. Every member of the + * room can read it, so a share put on this event would be every member holding + * every other member's share, which is a 1-of-n key wearing a t-of-n's clothes. + * + * ### Trusted no further than it can be checked + * + * The coordinator posts it, and the coordinator is untrusted by construction. + * A receiver therefore verifies rather than believes: the room's id *is* the + * threshold key derived at the path, so + * `SharedKeyDerivation.marmotGroupId(content, path) == chatRoomId` has to hold + * or the state is dropped. That is the same check + * `FrostSigningManager.completedKey` made by rederiving, kept rather than + * replaced -- this event makes the association explicit and cheap to look up, + * not easier to forge. + * + * ### Replaceable, by this app rather than by a relay + * + * Like every kind in [FrostSigningEvents] this is a rumor inside a Marmot group + * event, so no relay ever sees it and the addressable semantics of the 3xxxx + * range never fire. [DTag] is the room id and the newest state per room wins, + * which the local store enforces on its own. Being able to say it twice is what + * matters in practice: a redelivered announcement, or a second member saying the + * same true thing, folds away instead of accumulating. + * + * One room only ever names one key today. A group that re-runs its ceremony + * derives a *different* room from the new key, so rotation in place does not + * arise -- and if it ever does, the verification above is what has to change + * first, because a rotated key no longer derives the room it is announced in. + */ +object GroupKeyStateEvent { + /** + * Sits with the signing family in the Marmot inner-event space. 30320-30325 + * are a signing session; this is the standing fact a session is opened + * against, so it is adjacent rather than inside. + */ + val KIND: Kind = 30326 + + fun isGroupKeyStateKind(kind: Kind): Boolean = kind == KIND + + /** + * The tags for a state naming [dkgSessionId], for the room derived at [path]. + * + * The room id goes on as the `d` tag so the event is self-addressing: a + * reader can tell which room a state belongs to without the envelope it + * arrived in, which is what makes dropping a state announced into the wrong + * room a check rather than an assumption. + */ + fun assembleTags( + chatRoomId: String, + dkgSessionId: String, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): Array> = arrayOf( + DTag.assemble(chatRoomId), + FrostKeyTag.assemble(dkgSessionId), + FrostDerivationPathTag.assemble(path) + ) + + /** The room this state is about, or null if it names none. */ + fun parseChatRoomId(tags: Array>): String? = + tags.firstOrNull { it.size > 1 && it[0] == DTag.TAG_NAME }?.get(1)?.ifBlank { null } + + /** The ceremony whose share signs for this room, or null if it names none. */ + fun parseDkgSessionId(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId + + /** The derivation path, or null if it carries none or an unwalkable one. */ + fun parsePath(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostDerivationPathTag::parse)?.path + + /** + * The threshold public key a state announces, or null when the content is + * not one. + * + * Shape only -- 33 compressed bytes of hex. Whether it is *the* key for the + * room is settled by rederiving the room id from it, not by looking at it. + */ + fun parseThresholdPublicKey(content: String): HexKey? = + content.trim() + .takeIf { it.length == 66 && it.all { char -> char.isDigit() || char in 'a'..'f' || char in 'A'..'F' } } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt new file mode 100644 index 00000000..ad6d570f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostDerivationPathTag.kt @@ -0,0 +1,42 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure +import press.mantra.compose.managers.SharedKeyDerivation + +/** + * The path the room's id was derived at, `m/9420/0/0` style. + * + * Recorded rather than assumed. `SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH` + * is the only path anything walks today, but a room derived at a second one + * would be unfindable by a lookup that hardcodes the first, and the path is + * also what rebuilds the `TweakCache` a signing session needs. + * + * Hardened indices are rejected on parse: hardened derivation needs the parent + * private key, which in a threshold group nobody has, so a path carrying one + * was never walked. + */ +class FrostDerivationPathTag( + val path: List, +) { + fun toTagArray() = assemble(path = path) + + companion object { + const val TAG_NAME = "frost_path" + + fun parse(tag: Array): FrostDerivationPathTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val path = SharedKeyDerivation.parsePathString(tag[1]) ?: return null + + return FrostDerivationPathTag(path = path) + } + + fun assemble(path: List): Array = + arrayOf(TAG_NAME, SharedKeyDerivation.formatPath(path)) + + fun assemble(frostDerivationPathTag: FrostDerivationPathTag) = + assemble(path = frostDerivationPathTag.path) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt index 6d0ebb96..1aaa6558 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEvent.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag import press.mantra.compose.nostr.nip30303.tags.DialectIdTag import press.mantra.compose.nostr.nip30303.tags.LicenseTag import press.mantra.compose.nostr.nip30303.tags.UrlTag @@ -31,6 +32,17 @@ class ArtifactEvent( fun dialectIdReference() = tags.firstNotNullOfOrNull(DialectIdTag::parse)?.ref fun dialectId() = dialectIdReference()?.eventId + /** + * The label of the version this artifact starts life with. + * + * Carried on the artifact rather than in an event of its own because the + * group signs the artifact and nothing else. A first version proposed + * separately would need either a second quorum for one form or an id each + * device invents for itself, and invented ids differ on every device holding + * the same artifact. Null on artifacts written before this was declared. + */ + fun versionLabel() = tags.firstNotNullOfOrNull(ArtifactVersionMetadataTag::parse)?.versionLabel + companion object { const val KIND = 30300 const val ALT_DESCRIPTION = "Artifact" @@ -41,6 +53,7 @@ class ArtifactEvent( visibility: String, license: String, dialectId: String, + versionLabel: String, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, name, createdAt) { @@ -51,6 +64,7 @@ class ArtifactEvent( addUnique( DialectIdTag.assemble(dialectId) ) + addUnique(ArtifactVersionMetadataTag.assemble(versionLabel)) initializer() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4669fae9..a59c55d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -6,6 +6,7 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.MarmotReindexReport import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -79,10 +80,20 @@ interface ChatRepository { peers: List>, ): List + /** + * Sends [text] to the room, or -- when [directMessageRecipientPublicKey] is given -- + * to that one member of it. + * + * A direct message still travels as one group event that every member receives and + * can see the shape of; only its contents are private. See + * docs/marmot-direct-messages.md. NIP-17 rooms do not support it: there is no group + * for the rest of to see it, so a private message there is just a message. + */ suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind = ChatMessageEvent.KIND + messageType: Kind = ChatMessageEvent.KIND, + directMessageRecipientPublicKey: HexKey? = null ) fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? @@ -106,6 +117,19 @@ interface ChatRepository { onCompletion: () -> Unit ) + /** + * Reads the room's stored marmot group events again, so ones the group could + * not make sense of when they arrived get another chance now that the events + * they were waiting on have landed. + * + * Only events with nothing to show for them are replayed, so a room where + * nothing went wrong is left exactly as it was. + */ + suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport + companion object { val NO_OP_CHAT_REPOSITORY = object: ChatRepository { override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { @@ -192,7 +216,8 @@ interface ChatRepository { override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, - messageType: Kind + messageType: Kind, + directMessageRecipientPublicKey: HexKey? ) { // TODO("Not yet implemented") } @@ -232,6 +257,11 @@ interface ChatRepository { ) { TODO("Not yet implemented") } + + override suspend fun reindexMarmotGroupEvents( + chatRoomId: String, + userPublicKey: HexKey + ): MarmotReindexReport = MarmotReindexReport() } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index fcecf161..2024cd8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -2,6 +2,7 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgApprovalStep import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -42,6 +43,19 @@ interface DkgRepository { nostrPrivateKey: ByteArray ) + /** + * Tells [chatRoomId] which ceremony's key it signs with, as its first message. + * + * Called once by whoever creates the room. Null if the ritual has produced + * no key yet, or if the room does not derive from the one it produced -- + * both of which mean there is nothing true to announce. + */ + suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? + companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) @@ -65,6 +79,12 @@ interface DkgRepository { step: DkgApprovalStep, nostrPrivateKey: ByteArray ) = Unit + + override suspend fun announceGroupKeyState( + chatRoomId: String, + userPublicKey: HexKey, + session: DkgSession + ): GroupKeyState? = null } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 9d0b5d88..5605c335 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -92,17 +92,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? - suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String = DEFAULT_VISIBILITY, - license: String = DEFAULT_LICENSE, - ): MantraArtifact? - suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -176,17 +165,6 @@ interface MantraRepository { userPublicKey: HexKey, ): MantraDialect? = null - override suspend fun addArtifact( - localChatRoom: LocalChatRoom, - name: String, - url: String, - versionLabel: String, - dialectId: HexKey, - userPublicKey: HexKey, - visibility: String, - license: String, - ): MantraArtifact? = null - override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 44171146..c96ccc72 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -107,6 +107,23 @@ interface NostrRepository { activeKeyPair: KeyPair ) + /** + * Save a nostrEvent that arrived on a subscription rather than in answer to a queued + * request. + * + * The other two overloads take the request row an event was fetched for, because they + * also have to record which request produced it and flip that row to "processed". A + * live subscription has no row and never finishes, so it carries the relay and level + * itself. + */ + suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) + suspend fun queueSynchronizeNostrEvent( synchronizeNostrEventRequests: List, ) @@ -280,6 +297,16 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair + ) { + TODO("Not yet implemented") + } + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 5d451f8d..10dc902c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -62,7 +63,7 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.repository.MantraRepository -import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.view.model.AddArtifactViewModel import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -77,6 +78,7 @@ fun AddArtifactScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, ) { @@ -88,7 +90,8 @@ fun AddArtifactScreen( nostrRepository = nostrRepository, chatRepository = chatRepository, activeUserPublicKey = activeUserPublicKey, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) ) @@ -115,11 +118,12 @@ fun AddArtifactScreen( // one is picked; dialects are defined from the group detail screen. var selectedDialectId: String? by remember { mutableStateOf(null) } - // Of the required fields this is the only one the screen cannot ask - // for again: a dialect has to already exist, and nothing here can - // create one. So an unpicked dialect is a dead end rather than - // something to submit and be told about, and the button says so. - val canAddArtifact = selectedDialectId != null + // The two things the form cannot ask for again. A dialect has to + // already exist and nothing here can create one; a shared key has to + // have been ceremonied and this is not where that happens. Either + // missing is a dead end rather than something to propose and be told + // about afterwards, and the button says so. + val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign // M3 gives a FAB no `enabled`, so borrow the disabled colours every // other button in the app uses rather than inventing a shade here. @@ -168,15 +172,18 @@ fun AddArtifactScreen( urlField = urlFieldState, versionLabelField = versionLabelFieldState, dialectId = selectedDialectId, - onSuccess = { artifactId -> - // Open the newly created artifact, removing this - // add screen from the back stack. + onSuccess = { sessionId -> + // Onto the session rather than to the + // artifact. Nothing has been created yet + // -- the artifact appears when enough + // members sign -- so a detail screen for + // a row that does not exist would read as + // a failure. onNavigateToRouteAndPopUpInclusive.invoke( - ArtifactDetailRoute( + FrostSigningRoute( activeUserPublicKey = activeUserPublicKey, - artifactId = artifactId, chatRoomId = chatRoomId, - relayHint = relayHint + sessionId = sessionId ) ) }, @@ -191,9 +198,9 @@ fun AddArtifactScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add artifact" + contentDescription = "Propose artifact" ) - Text("Add Artifact") + Text("Propose Artifact") } } ) @@ -209,6 +216,16 @@ fun AddArtifactScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text("Add Artifact to the group library") + + if (!addArtifactUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign an " + + "artifact into its library. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth() .background(BottomAppBarDefaults.containerColor), @@ -385,6 +402,7 @@ private fun AddArtifactScreenPreview() { nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index ba444544..bc19ed8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.LibraryBooks @@ -23,6 +25,7 @@ import androidx.compose.material.icons.filled.Unsubscribe import androidx.compose.material.icons.filled.WaterfallChart import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider @@ -493,6 +496,22 @@ fun ChatRoomDetailScreen( Text("Delete Group") } } + + // Only MLS rooms have group events to read again. A NIP-17 + // room's messages are gift wraps, which carry no ordering + // for anything to go wrong with. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + HorizontalDivider() + } + + item { + ReindexMarmotGroupEventsButton( + reindexState = chatRoomDetailViewModel.reindexState, + onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + ) + } + } } } } @@ -539,6 +558,77 @@ fun ChatRoomDetailScreen( } } +/** + * Reads the room's stored marmot group events again. + * + * Sits at the very bottom, below leaving and deleting, because it is a repair + * rather than something anyone should need in the ordinary course of using the + * group. It says what it found afterwards -- a replay that recovered nothing + * looks identical to one that was never pressed, and the difference between + * "nothing was broken" and "nothing could be fixed" is worth telling. + */ +@Composable +private fun ReindexMarmotGroupEventsButton( + reindexState: ChatRoomDetailViewModel.ReindexState, + onReindex: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + TextButton( + enabled = reindexState !is ChatRoomDetailViewModel.ReindexState.Running, + onClick = onReindex + ) { + if (reindexState is ChatRoomDetailViewModel.ReindexState.Running) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Default.Autorenew, + contentDescription = "Reindex group events" + ) + } + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Reindex Events") + } + + when (reindexState) { + is ChatRoomDetailViewModel.ReindexState.Done -> { + val report = reindexState.report + Text( + text = when { + report.isNoOp -> "Nothing to reindex · ${report.stored} event(s) all read" + report.recovered > 0 && report.failed > 0 -> + "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable" + report.recovered > 0 -> "Recovered ${report.recovered} of ${report.unresolved} event(s)" + else -> "${report.failed} event(s) still unreadable" + }, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + is ChatRoomDetailViewModel.ReindexState.Failed -> { + Text( + text = reindexState.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + + else -> Unit + } + } +} + @Preview @Composable private fun ChatRoomMessagingScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index c77fa7ef..e909ab6c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -4,15 +4,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBarDefaults @@ -168,6 +172,49 @@ fun ChatRoomMessagingScreen( .background(BottomAppBarDefaults.containerColor) .navigationBarsPadding() ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + Box( modifier = Modifier.fillMaxWidth() ) { @@ -177,11 +224,25 @@ fun ChatRoomMessagingScreen( colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } ), placeholder = { Text( - text = "Say what now?" + text = if (directMessageRecipient != null) { + "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" + } else { + "Say what now?" + } ) }, trailingIcon = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 38282f75..386e3bce 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,7 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent @@ -223,10 +224,10 @@ fun FrostSigningScreen( } } - if (session.signApprovedAt == null && - session.stage != FrostSigningStage.COMPLETE && - session.stage != FrostSigningStage.FAILED - ) { + // Asked rather than re-derived: the manager owns when a session + // is still waiting on its owner, and a second copy of that rule + // here is a second copy to keep in step. + if (FrostSigningManager.isAwaitingApproval(session)) { HorizontalDivider() Text( @@ -304,7 +305,15 @@ private fun WhatIsBeingSigned(event: Event?) { .joinToString(" · ") } - ArtifactEvent.KIND -> "New artifact" to event.content + ArtifactEvent.KIND -> "New artifact" to ArtifactEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { artifact -> + // The url is the substance of an artifact -- signing one is putting + // the group's name to what it points at -- so it goes next to the + // name rather than being left for the detail screen afterwards. + listOfNotNull(event.content, artifact.versionLabel(), artifact.url()) + .joinToString(" · ") + } ChapterEvent.KIND -> "New chapter" to ChapterEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig 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 354ffafa..9332b751 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 @@ -3,10 +3,14 @@ package press.mantra.compose.ui.composable.navigation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import press.mantra.compose.AppLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -231,6 +235,22 @@ fun MantraNavHost( // TODO: Produce a synchronization UI element... + // The one place in the app that knows whether it is on screen. Everything that holds a + // relay connection open reads AppLifecycle rather than a lifecycle owner, because those + // consumers are application-scoped coroutines that outlive any composition. + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> AppLifecycle.enteredForeground() + Lifecycle.Event.ON_STOP -> AppLifecycle.enteredBackground() + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(lifecycleOwner) { navigationViewModel.navigationUIState.collect { state -> when (state) { @@ -810,9 +830,12 @@ fun MantraNavHost( nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, mantraRepository = databaseMantraRepository, - onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. navController.navigate( - route = chatRoomDetailRoute + route = signingRoute ) { popUpTo(route) { inclusive = true diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt index 98a02974..582698f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.AddArtifactUIState @@ -30,6 +32,7 @@ class AddArtifactViewModel( val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, ): ViewModel() { var addArtifactUIState: AddArtifactUIState by mutableStateOf(initialAddArtifactUIState) @@ -52,11 +55,25 @@ class AddArtifactViewModel( AddArtifactUIState.Loaded( localChatRoom = localChatRoom, dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), ) } } } + /** + * Asks the group to sign an artifact into its library. + * + * The artifact is not created here and does not exist yet. What goes out is + * a proposal to sign it, and the artifact appears -- on every member's + * device at once, authored by the group's shared key rather than by whoever + * typed it -- when enough members have signed. + * + * That is the difference from submitting one. A submission says "I am + * putting this in front of the group" and the group's only recourse + * afterwards is social. A signature is the group saying it, and it takes a + * quorum to say. A library is the group's, so the second is the honest one. + */ fun addArtifact( localChatRoom: LocalChatRoom, nameField: TextFieldState, @@ -65,57 +82,65 @@ class AddArtifactViewModel( dialectId: HexKey?, visibility: String = MantraRepository.DEFAULT_VISIBILITY, license: String = MantraRepository.DEFAULT_LICENSE, - onSuccess: (artifactId: String) -> Unit, + onSuccess: (sessionId: String) -> Unit, onFailure: () -> Unit ) { - localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup -> - val name = nameField.text.toString() - val url = urlField.text.toString() - val versionLabel = versionLabelField.text.toString() + val name = nameField.text.toString() + val url = urlField.text.toString() + val versionLabel = versionLabelField.text.toString() - // addArtifact requires an existing source dialect; they are defined - // from the group detail screen, not here. - if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { - onFailure.invoke() - return - } + // An artifact requires an existing source dialect; they are signed into + // existence from the group detail screen, not here. + if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) { + onFailure.invoke() + return + } - // Guard against double submits from repeated FAB taps. - if (isActionPending.value) return - isActionPending.value = true + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true - viewModelScope.launch(Dispatchers.IO) { - val artifact = runCatching { - mantraRepository.addArtifact( - localChatRoom = localChatRoom, - name = name, - url = url, - versionLabel = versionLabel, - dialectId = dialectId, - userPublicKey = activeUserPublicKey, - visibility = visibility, - license = license, - ) - }.onFailure { error -> - logger.e("Failed to add artifact", error) - }.getOrNull() + viewModelScope.launch(Dispatchers.IO) { + // The version label rides on the artifact rather than following it as + // a second event. The group signs the artifact; a first version + // proposed on its own would cost a second quorum for one form, and + // every device derives the same first version from what was signed. + val artifactEventTemplate = ArtifactEvent.build( + name = name, + url = url, + visibility = visibility, + license = license, + dialectId = dialectId, + versionLabel = versionLabel, + ) - if (artifact != null) { - nameField.clearText() - urlField.clearText() - versionLabelField.clearText() + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = artifactEventTemplate.kind, + tags = artifactEventTemplate.tags, + content = artifactEventTemplate.content, + ) + }.onFailure { error -> + logger.e("Failed to propose an artifact for signing", error) + }.getOrNull() - viewModelScope.launch(Dispatchers.Main) { - onSuccess.invoke(artifact.id) - } - } else { - viewModelScope.launch(Dispatchers.Main) { - onFailure.invoke() - } + if (session != null) { + nameField.clearText() + urlField.clearText() + versionLabelField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() } - - isActionPending.value = false } + + isActionPending.value = false } } @@ -129,7 +154,8 @@ class AddArtifactViewModel( initialAddArtifactUIState: AddArtifactUIState = AddArtifactUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, - mantraRepository: MantraRepository + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { AddArtifactViewModel( @@ -139,9 +165,10 @@ class AddArtifactViewModel( initialAddArtifactUIState = initialAddArtifactUIState, nostrRepository = nostrRepository, chatRepository = chatRepository, - mantraRepository = mantraRepository + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository ) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 2d2a20d6..4ba70a91 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn @@ -33,9 +34,12 @@ import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Pending import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator @@ -61,20 +65,21 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged @@ -92,6 +97,60 @@ class ChatMessageListViewModel( val isReceiverChatMessageRelayListMissing: MutableState = mutableStateOf(false) + /** + * The member the next message goes to privately, or null to send it to the room. + * + * Held here rather than on the composer because it survives recomposition and is read + * by both the input and the send call. It is cleared on send: a private message is a + * deliberate act each time, and leaving the composer armed after one is how somebody + * sends the room's next message to one person, or one person's to the room. + */ + var directMessageRecipient: Participant? by mutableStateOf(null) + private set + + /** The message whose actions are open, or null. One at a time. */ + var openMessageActionsFor: Long? by mutableStateOf(null) + private set + + fun startDirectMessage(participant: Participant) { + logger.d("Arming a direct message to ${participant.participantPublicKey}") + directMessageRecipient = participant + openMessageActionsFor = null + } + + fun cancelDirectMessage() { + directMessageRecipient = null + } + + fun openMessageActions(chatMessageId: Long?) { + openMessageActionsFor = chatMessageId + } + + /** The member behind a pubkey, if they are in this room. */ + fun participantFor(publicKey: HexKey?): Participant? = + publicKey?.let { key -> + localChatRoom.localParticipants + .map { it.participant } + .firstOrNull { it.participantPublicKey == key } + } + + /** + * What to call somebody, for a line that has to name them. + * + * Falls back to the key itself rather than to "unknown": a direct message names a + * real member of this room, and a line that cannot say which one is worse than an + * ugly one. + */ + fun nameFor(publicKey: HexKey?): String { + if (publicKey == null) return "someone" + + return localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + } + fun initiate() { logger.d("init") scheduleSynchronization() @@ -112,63 +171,56 @@ class ChatMessageListViewModel( } } + /** + * Finds out where this room's participants read their messages. + * + * This used to schedule the room's message sync as well — gift wraps for a NIP-17 room, + * group events for an MLS one. Both are now covered by the subscriptions + * LiveSubscriptionManager holds open for the whole account, so opening a chat no longer + * asks for its messages; they are already arriving. + * + * What is left is discovery, not message sync: if we do not hold a participant's + * kind-10050 we cannot address a message to them, and that is worth resolving the moment + * a chat is opened rather than whenever a background pass gets to it. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { - // Sync Notifications... might want to also run this in the background if (localChatRoom.chatRoom.mlsGroupState == null) { localChatRoom.localParticipants.filter { it.participant.participantPublicKey != localChatRoom.chatRoom.userPublicKey }.forEach { recipients -> val chatMessageRelayListEvent = chatRepository.getChatMessageRelayForPublicKey(recipients.participant.participantPublicKey) - val relayAndSynchronizationFilter = if (chatMessageRelayListEvent != null) { - // Sync messages from this relay... - Pair( - chatMessageRelayListEvent.relays(), - SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(recipients.participant.participantPublicKey)) - ), - limit = 50 - ) - ) + // Opening a conversation used to refresh our own inbox here, with + // Nip17Filters.inbox on our own DM relays. That is now redundant rather + // than wrong: LiveSubscriptionManager holds exactly that filter open for + // the whole account and reconciles it on every foreground, so there is + // nothing left for opening a chat to ask for. + if (chatMessageRelayListEvent != null) return@forEach - } else { - // Try to find the ChatMessageRelayList for this participant... - isReceiverChatMessageRelayListMissing.value = true - // TODO: compute the timestamp for when list we sent messages and use that as since... - Pair( - Relays.DefaultDMRelayList, - SynchronizationFilter( - kinds = arrayOf( - ChatMessageRelayListEvent.KIND, - ), - authors = arrayOf( - recipients.participant.participantPublicKey - ), - limit = 50 - ) - ) - } + isReceiverChatMessageRelayListMissing.value = true - logger.i("GiftWrapFilter: ${relayAndSynchronizationFilter.second}") + val chatMessageRelayListFilter = SynchronizationFilter( + kinds = arrayOf( + ChatMessageRelayListEvent.KIND, + ), + authors = arrayOf( + recipients.participant.participantPublicKey + ), + limit = 50 + ) - val negentropySynchronizeRequests = relayAndSynchronizationFilter.first.map { normalizedRelayUrl -> + val negentropySynchronizeRequests = Relays.DefaultDMRelayList.map { normalizedRelayUrl -> NegentropySynchronizeRequest( id = NegentropySynchronizeRequest.computeId( relayURL = normalizedRelayUrl.url, - synchronizationFilter = relayAndSynchronizationFilter.second + synchronizationFilter = chatMessageRelayListFilter ), - purpose = if (isReceiverChatMessageRelayListMissing.value) { - "chat-message-relays" - } else { - "sent-messages" - }, - synchronizationFilter = relayAndSynchronizationFilter.second, + // Only one case reaches here now, so the purpose is no longer a + // question. + purpose = "chat-message-relays", + synchronizationFilter = chatMessageRelayListFilter, relayURL = normalizedRelayUrl.url, level = 0 ) @@ -179,39 +231,7 @@ class ChatMessageListViewModel( negentropySynchronizeRequests ) } - } else { - // Sync mlsMessages - val relayChatRoomMaps = Relays.DefaultDMRelayList.map { dmRelay -> - dmRelay.url to listOf( - localChatRoom.chatRoom.id - ) - } - - val negentropySyncRequests = relayChatRoomMaps.map { relayChatRoomMap -> - val mlsGroupMessageFilter = SynchronizationFilter( - kinds = arrayOf(GroupEvent.KIND), - tags = mapOf("h" to relayChatRoomMap.second), - ) - logger.d("mlsGroupMessageFilter: $mlsGroupMessageFilter") - - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relayURL = relayChatRoomMap.first, - synchronizationFilter = mlsGroupMessageFilter - ), - purpose = "mlsMessages", - synchronizationFilter = mlsGroupMessageFilter, - relayURL = relayChatRoomMap.first, - level = 0 - ) - } - - logger.d("negentropySyncRequests: ${negentropySyncRequests.map { it.synchronizationFilter }}") - nostrRepository.queueNegentropySynchronizeRequest( - negentropySyncRequests - ) } - } } @@ -263,30 +283,17 @@ class ChatMessageListViewModel( ) } else { - // A request line is answered when the step it asked for - // has since been published by this device -- which is - // exactly what approving it does. The transcript already - // records that as an authored line, so the answer is here - // in the list rather than in the session, and it stays - // right for a room that has run more than one ceremony. - val answeredRequests = chatRoomDetailMessageListUIState + // Which request lines are still asking something of the + // reader. Read off the transcript rather than the session + // -- the rows are what a line rendered days later has -- + // and both rules live on ChatMessage, where they can be + // stated once and tested. + val messages = chatRoomDetailMessageListUIState .chatMessageList - .mapNotNull { request -> - val published = ( - ChatMessage.DKG_REQUEST_FULFILMENTS + - ChatMessage.FROST_REQUEST_FULFILMENTS - )[request.chatMessage.messageType] - ?: return@mapNotNull null + .map { it.chatMessage } - val done = chatRoomDetailMessageListUIState.chatMessageList.any { - it.chatMessage.messageType == published && - it.chatMessage.isUserMessage && - it.chatMessage.createdAt >= request.chatMessage.createdAt - } - - request.chatMessage.id.takeIf { done } - } - .toSet() + val answeredRequests = ChatMessage.answeredRequests(messages) + val settledRequests = ChatMessage.settledRequests(messages) LazyColumn( modifier = Modifier.fillMaxWidth().padding(5.dp), @@ -346,11 +353,26 @@ class ChatMessageListViewModel( RitualNotice( localChatMessage = localChatMessage, isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, onClick = onOpenSharedKey ) return@items } + // A private message this device cannot open. Everything + // about it is known except the one thing that matters, + // so it is a notice rather than an empty bubble -- + // which would read as the sender having said nothing. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && + localChatMessage.chatMessage.content.isBlank() + ) { + PrivateMessageNotice( + sender = nameFor(localChatMessage.chatMessage.senderPublicKey), + recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a @@ -360,6 +382,7 @@ class ChatMessageListViewModel( RitualNotice( localChatMessage = localChatMessage, isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, onClick = onOpenSigning ) return@items @@ -378,12 +401,22 @@ class ChatMessageListViewModel( Arrangement.Start } ) { + // Only somebody else's message, and only in a + // group -- a NIP-17 room has no audience for a + // message to be private from. + val canReplyPrivately = + localChatRoom.chatRoom.mlsGroupState != null && + !localChatMessage.chatMessage.isUserMessage && + participantFor(localChatMessage.chatMessage.senderPublicKey) != null + Card( modifier = Modifier.widthIn( max = screenWidth * 0.8f ).wrapContentWidth(), onClick = { - + if (canReplyPrivately) { + openMessageActions(localChatMessage.chatMessage.id) + } }, ) { Column( @@ -405,6 +438,34 @@ class ChatMessageListViewModel( ) } + // A readable direct message must never + // pass for a public one. The label says + // who the other party is, since that is + // the thing a reader would otherwise + // assume was the whole room. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = if (localChatMessage.chatMessage.isUserMessage) { + "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" + } else { + "Private to you" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + SelectionContainer { Text( @@ -453,6 +514,24 @@ class ChatMessageListViewModel( } } } + + DropdownMenu( + expanded = openMessageActionsFor == localChatMessage.chatMessage.id, + onDismissRequest = { openMessageActions(null) } + ) { + DropdownMenuItem( + text = { + Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") + }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + onClick = { + participantFor(localChatMessage.chatMessage.senderPublicKey) + ?.let { startDirectMessage(it) } + } + ) + } } } } @@ -486,12 +565,17 @@ class ChatMessageListViewModel( fun sendMessage(textFieldState: TextFieldState) { if (textFieldState.text.isNotBlank()) { val text = textFieldState.text.toString() + // Read and cleared together with the text, before the send suspends, so a + // second message cannot inherit the first one's audience. + val recipient = directMessageRecipient textFieldState.clearText() + directMessageRecipient = null viewModelScope.launch(Dispatchers.IO) { chatRepository.sendChatMessage( text = text, - localChatRoom = localChatRoom + localChatRoom = localChatRoom, + directMessageRecipientPublicKey = recipient?.participantPublicKey ) } } @@ -518,6 +602,43 @@ class ChatMessageListViewModel( } } +/** + * A private message this device cannot read, as a system line. + * + * Deliberately not a bubble. The group is meant to know that a private message was sent + * and to whom -- that is the honest half of the feature -- but an empty bubble attributed + * to the sender would read as them having said nothing, and a bubble with placeholder text + * would read as them having said the placeholder. + * + * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. + */ +@Composable +private fun PrivateMessageNotice( + sender: String, + recipient: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "$sender sent a private message to $recipient", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } +} + /** * A ChillDKG milestone, as a system line across the transcript. * @@ -531,6 +652,7 @@ class ChatMessageListViewModel( private fun RitualNotice( localChatMessage: LocalChatMessage, isAnswered: Boolean, + isSettled: Boolean, onClick: () -> Unit, ) { val chatMessage = localChatMessage.chatMessage @@ -572,10 +694,13 @@ private fun RitualNotice( // quiet; these are not. // An answered request is history, not a summons: it keeps its stage's icon so // the step is still recognisable, but drops the colour and the call to action. + // So is a settled one -- declined, or signed by a quorum that did not need this + // member. Nothing was answered there, so it gets no tick, but offering to + // review it would be offering a decision that has already gone by. val isRequest = ( chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES || chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES - ) && !isAnswered + ) && !isAnswered && !isSettled val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 9e1d7068..b2cff93a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -18,9 +18,11 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.database.model.types.MarmotReindexReport class ChatRoomDetailViewModel( val chatRoomId: String, @@ -35,6 +37,26 @@ class ChatRoomDetailViewModel( var chatRoomDetailUIState: ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState) private set + /** + * Where the room's replay has got to, for the button that started it. + * + * Held here rather than in [ChatRoomDetailUIState] because a replay does not + * change what the screen is showing -- the message list is observed elsewhere + * and updates itself -- it only changes what the button has to say for itself. + */ + var reindexState: ReindexState by mutableStateOf(ReindexState.Idle) + private set + + sealed interface ReindexState { + data object Idle : ReindexState + + data object Running : ReindexState + + data class Done(val report: MarmotReindexReport) : ReindexState + + data class Failed(val message: String) : ReindexState + } + private val logger = Logger.withTag(TAG) @@ -81,6 +103,39 @@ class ChatRoomDetailViewModel( } } + /** + * Reads the room's stored marmot group events again. + * + * Nostr relays impose no ordering, so a group event can arrive before the group + * can read it -- a message from an epoch whose commit has not landed, a commit + * for an epoch ahead of the local one. Nothing revisits those once the gap + * fills, which is what this is for. + * + * Only events with nothing to show for them are replayed, so pressing this on a + * room where nothing went wrong changes nothing. The feed is observed, so + * anything recovered appears in the message list without a refresh. + */ + fun reindexMarmotGroupEvents() { + if (reindexState is ReindexState.Running) return + + reindexState = ReindexState.Running + viewModelScope.launch(Dispatchers.IO) { + reindexState = try { + ReindexState.Done( + chatRepository.reindexMarmotGroupEvents( + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey, + ) + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.e("Failed to reindex $chatRoomId", e) + ReindexState.Failed(e.message ?: "Reindex failed") + } + } + } + fun softDeleteGroup( localChatRoom: LocalChatRoom, onPopBackToRoute: (Route) -> Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index 994f76bb..6ef606ae 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -27,13 +27,13 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ChatRoomListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -50,7 +50,6 @@ class ChatRoomListViewModel( fun initiate() { logger.d("init") - scheduleSynchronization() observeChatRoomFeed() } @@ -68,19 +67,21 @@ class ChatRoomListViewModel( } } + /** + * Reconciles this account's chat history against the relays: gift wraps addressed to us, + * and group events for every group we are in. + * + * No longer called on open. LiveSubscriptionManager holds both of those subscriptions + * open for as long as the app is in the foreground and runs exactly this reconciliation + * when it returns from the background, so opening the list is no longer a reason to ask. + * This stays for an explicit user-initiated refresh — the one case the live tier does not + * answer, because it is the user saying they believe something is missing. + */ fun scheduleSynchronization() { logger.d("scheduleSynchronization") viewModelScope.launch(Dispatchers.IO) { // Sync Notifications... might want to also run this in the background - val chatRequestFilter = SynchronizationFilter( - kinds = arrayOf( - GiftWrapEvent.KIND, - ), - tags = mapOf( - Pair("p", listOf(publicKey)) - ), - limit = 50 - ) + val chatRequestFilter = Nip17Filters.inbox(publicKey) nostrRepository.queueNegentropySynchronizeRequest( Relays.DefaultDMRelayList.shuffled().map { normalizedRelayUrl -> NegentropySynchronizeRequest( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 8ae7816f..81f26cf0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -251,7 +251,8 @@ class DkgRitualViewModel( if (isActionPending.value) return val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val thresholdPublicKey = loaded.session?.thresholdPublicKey ?: return + val session = loaded.session ?: return + val thresholdPublicKey = session.thresholdPublicKey ?: return val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() if (nostrPrivateKey == null) { @@ -383,6 +384,28 @@ class DkgRitualViewModel( logger.e("Failed to add members to admin group $groupId", it) }.getOrElse { addable.map { (publicKey, _) -> publicKey } } + // The room's first message: which ceremony's key it signs with, and + // the path its id was derived at. What a signer reaches for when a + // signing request arrives and it has to pick one of its shares. + // + // After the members are added rather than before, which is the only + // order that works: adding them commits a new epoch, and MLS will not + // let a member read what was encrypted before the epoch they joined + // at. Announced first, the announcement would reach nobody but its + // author. It is still the room's first *application* message -- what + // comes before it is handshake. + // + // A member invited later still misses it for the same reason, and is + // left where every member was before this event existed: falling back + // to FrostSigningManager.completedKey's rederivation. Re-announcing + // on invite is the fix, and is cheap because a repeat announcement + // folds away rather than accumulating. + dkgRepository.announceGroupKeyState( + chatRoomId = localChatRoom.chatRoom.id, + userPublicKey = activeUserPublicKey, + session = session + ) + isActionPending.value = false if (notAdded.isNotEmpty()) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index 9f8fa9aa..c4fe428c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -8,10 +8,13 @@ import press.mantra.compose.database.model.BroadcastNostrEventRequest import press.mantra.compose.database.model.SynchronizeNostrEventRequest import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.network.dto.toRelayDTO +import press.mantra.compose.AppLifecycle +import press.mantra.compose.managers.LiveSubscriptionManager import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.relays.RelaysSocketManager +import press.mantra.compose.network.relays.isRelayBackPressure import press.mantra.compose.network.sockets.NostrIncomingMessage -import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.DefaultNostrSocketClientFactory import press.mantra.compose.repository.CachingImportRepository import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository @@ -65,11 +68,23 @@ class SynchronizationViewModel( val relaysSocketManager = RelaysSocketManager( activeWalletStateFlow = activeWalletStateFlow, - nostrSocketClientFactory = NostrSocketClientFactory, + nostrSocketClientFactory = DefaultNostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, ) + /** + * The chat subscriptions that stay open while a wallet is active. Built here because it + * has to share this class's RelaysSocketManager — a second one would mean a second + * RelayPool and a duplicate socket per relay. + */ + private val liveSubscriptionManager = LiveSubscriptionManager( + relaysSocketManager = relaysSocketManager, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + isForeground = AppLifecycle.isForeground, + ) + companion object { private const val TAG = "SynchronizationViewModel" @@ -150,6 +165,10 @@ class SynchronizationViewModel( * delivered to every collector on the socket, logged it once per open collector. * Requests queue on this semaphore instead, so the backlog still drains, just not * all at once. + * + * Live subscriptions are deliberately outside this budget (see LiveSubscriptionManager): + * a subscription that never finishes would hold a permit forever. The two have to be + * read together against what a relay tolerates per connection, not separately. */ private val subscriptionSlots = Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS) @@ -167,21 +186,6 @@ class SynchronizationViewModel( } } - /** - * Whether a relay's CLOSED/NOTICE reason means "you are asking for too much" - * rather than "I cannot serve this". NIP-01 gives `rate-limited:` as the - * machine-readable prefix; the free-text forms are what relays actually send. - */ - private fun isBackPressure(reason: String?): Boolean { - val text = reason?.lowercase() ?: return false - - return text.startsWith("rate-limited") || - text.contains("rate limit") || - text.contains("too many") || - text.contains("concurrent") || - text.contains("slow down") - } - init { scope.launch { activeWalletStateFlow.collectLatest { activeWallet -> @@ -213,6 +217,10 @@ class SynchronizationViewModel( launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } + // Runs until cancelled rather than draining a queue, but it belongs + // here for the same reason the pumps do: it needs the active wallet's + // key pair, and a wallet switch must tear it down. + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } } } } @@ -512,7 +520,7 @@ class SynchronizationViewModel( // is telling us to ease off: answering back-pressure // by opening another subscription is what turned one // refusal into a flood of them. - if (isBackPressure(nostrIncomingMessage.message)) { + if (isRelayBackPressure(nostrIncomingMessage.message)) { logger.w("Relay ${negentropySynchronizeRequest.relayURL} is rate limiting; not retrying this request") } else if (negentropySynchronizeRequest.purpose != "mlsMessages") { nostrRepository.queueSynchronizeNostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt index af593edb..32fe947f 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddArtifactUIState.kt @@ -7,6 +7,13 @@ sealed interface AddArtifactUIState { data class Loaded( val localChatRoom: LocalChatRoom, val dialects: List = emptyList(), + + /** + * Whether the group holds a shared key. An artifact is signed into + * existence now rather than submitted, so a group without one cannot + * add one here at all. + */ + val canSign: Boolean = false, ): AddArtifactUIState data class Error( @@ -15,4 +22,3 @@ sealed interface AddArtifactUIState { data object Loading: AddArtifactUIState } - diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt new file mode 100644 index 00000000..6502f728 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/GiftWrapMessageTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.test.runTest +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 + +/** + * Who can open a gift wrap, and what becomes of everyone else's. + * + * NIP-59 encrypts a wrap under ECDH(ephemeralPriv, recipientPub), and + * [GiftWrapEvent.create] discards that ephemeral key before it returns. The + * recipient named in the `p` tag is therefore the only party who can ever unseal + * one -- the sender included. Reading a wrap that is not ours is not a decryption + * that might fail, it is one that cannot be attempted, and the code that tried + * anyway threw `IllegalStateException: Invalid Mac` out of Nip44 and took the + * enclosing Room transaction down with it, so the event was rolled back and + * re-fetched on every later sync. + * + * These run real secp256k1 rather than recorded fixtures on purpose: the property + * under test is about the key agreement itself, and a fixture would only prove the + * fixture still parses. + */ +class GiftWrapMessageTest { + + private val us = KeyPair() + private val peer = KeyPair() + private val stranger = KeyPair() + + /** + * A real wrap, sealed the way DatabaseChatRepository seals one and then mapped + * into the entity the way NostrEvent.toGiftWrapMessageWithReceiverPTag maps it. + */ + private fun wrap( + sender: KeyPair, + recipient: KeyPair, + ): GiftWrapMessage { + val signer = NostrSignerSync(sender) + + val seal = signer.signNormal( + createdAt = SEALED_AT, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = WRAPPED_AT, + ) + + return GiftWrapMessage( + id = giftWrap.id, + publicKey = giftWrap.pubKey, + receiverPublicKey = recipient.pubKey.toHexKey(), + receiverRelayHit = null, + content = giftWrap.content, + signature = giftWrap.sig, + nostrEventId = giftWrap.id, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to us gives up its seal`() = runTest { + val seal = wrap(sender = peer, recipient = us).decryptGiftWrapSeal(us) + + assertNotNull(seal) + // Only the wrap was anonymous. The seal inside carries the real sender, which + // is what lets the impersonation check downstream compare it to the payload. + assertEquals(peer.pubKey.toHexKey(), seal.publicKey) + } + + @Test + fun `someone else's mail comes back null rather than throwing`() = runTest { + // The event from the crash report: a wrap between two other people, pulled in + // by a filter that named a peer where it should have named us. + val theirs = wrap(sender = stranger, recipient = peer) + + assertNull(theirs.decryptGiftWrapSeal(us)) + } + + @Test + fun `not even the sender can reopen what they sent`() = runTest { + // What the old branch called "a message we may have sent" and tried to decrypt + // regardless. The key that encrypted it no longer exists anywhere; holding the + // sending identity buys nothing back. + val ours = wrap(sender = us, recipient = peer) + + assertNull(ours.decryptGiftWrapSeal(us)) + } + + @Test + fun `isAddressedTo reads the p tag whatever case it arrived in`() { + val message = wrap(sender = peer, recipient = us) + + assertTrue(message.isAddressedTo(us)) + assertFalse(message.isAddressedTo(peer)) + assertTrue( + message + .copy(receiverPublicKey = message.receiverPublicKey.uppercase()) + .isAddressedTo(us), + ) + } + + @Test + fun `isAddressedTo answers exactly what unsealing would`() = runTest { + // NostrDao skips indexing on isAddressedTo and throws GiftWrapUnsealException on + // a null seal. Should those two ever disagree, one path or the other is wrong: + // either mail we can open is skipped, or the transaction rolls back again. + listOf( + wrap(sender = peer, recipient = us), + wrap(sender = stranger, recipient = peer), + wrap(sender = us, recipient = peer), + ).forEach { message -> + assertEquals( + message.isAddressedTo(us), + message.decryptGiftWrapSeal(us) != null, + "isAddressedTo and decryptGiftWrapSeal disagree on ${message.id}", + ) + } + } + + private companion object { + const val SEALED_AT = 1_700_000_000L + const val WRAPPED_AT = 1_700_000_100L + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt new file mode 100644 index 00000000..c59e65ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/InitialArtifactVersionTest.kt @@ -0,0 +1,144 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * The first version of an artifact is derived, not delivered. + * + * The group signs an artifact and nothing else, so the version it starts life + * with is not an event anybody sent: every device builds the row for itself out + * of the artifact it already holds. That only works while every device builds + * the *same* row, and nothing about the ids would show it if they stopped — + * they are content hashes, opaque hex either way. What would show is a group + * that quietly disagrees about which version a chapter hangs off, with the + * artifact looking identical on every screen. + */ +class InitialArtifactVersionTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + private val chatRoomId = "room" + + private fun signedArtifact( + name: String = "In Detention", + versionLabel: String = "1.0", + createdAt: Long = 1_700_000_000, + ): ArtifactEvent { + val template = ArtifactEvent.build( + name = name, + url = "example.com", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = createdAt, + ) + + // Hashed rather than made up, so two fixtures that differ are two + // different artifacts here for the same reason they would be in the app. + return ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "d".repeat(128) + ) + } + + @Test + fun `the derived version is a function of the artifact and nothing else`() { + // Every input has to come off the artifact. Reading the clock here would + // still agree with itself twice in a row -- and disagree between two + // devices that applied the same artifact minutes apart, which is the + // case nobody can reproduce on demand. So the timestamp is checked + // against the artifact's rather than against a second derivation. + val artifact = signedArtifact(createdAt = 1_700_000_000) + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(version) + assertEquals(1_700_000_000, version.createdAt.epochSeconds) + } + + @Test + fun `two devices derive the same first version from the same artifact`() { + val artifact = signedArtifact() + + val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertNotNull(mine) + assertEquals(mine.id, theirs?.id) + assertEquals(mine.createdAt, theirs?.createdAt) + } + + @Test + fun `an artifact signed at a different moment derives a different version`() { + // The artifact's own timestamp is bound into the derived id, so two + // proposals identical but for when they were made stay two artifacts + // with two first versions rather than colliding on one row. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `the derived version hangs off the artifact and carries what it declared`() { + val artifact = signedArtifact(versionLabel = "First Edition") + + val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId) + + assertEquals(artifact.id, version?.artifactId) + assertEquals("First Edition", version?.versionLabel) + // Authored by whoever authored the artifact -- the group, once signed -- + // and unsigned, because nobody signed this. + assertEquals(groupKey, version?.publicKey) + assertEquals("", version?.signature) + } + + @Test + fun `the label is bound into the id rather than hung beside it`() { + // Two artifacts alike but for the label must not derive one version + // between them: the id has to come from the whole event, or a group + // renaming a version would leave the row it replaces in place. + val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId) + val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId) + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first.id, second.id) + } + + @Test + fun `an artifact that declares no version derives none`() { + // Artifacts written before the artifact carried its first version. + val declared = signedArtifact() + val silent = ArtifactEvent( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt new file mode 100644 index 00000000..cd898372 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotChatLineReconciliationTest.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.time.Instant + +/** + * What happens to a room's chat line when its group event is read a second time. + * + * [ChatMessage.id] is autogenerated, so writing a freshly built line always + * inserts. That is correct exactly once. Reading the same kind:445 again -- which + * is what a reindex does -- would otherwise leave the room showing the recovered + * message twice, once as the placeholder that was written when it could not be + * read and once as itself. And the other way round matters just as much: a group + * event this device sent already has its line, and a replay that cannot read our + * own ratchet-consumed message must not replace it with "Undecryptable Message". + * + * Both directions are decided by [ChatMessage.reconcileMarmotLine], which is why + * they are asserted rather than left to the shape of the calling code. + */ +class MarmotChatLineReconciliationTest { + + private val groupEventId = "a".repeat(64) + private val sender = "b".repeat(64) + private val room = "c".repeat(64) + + private fun line( + id: Long = 0, + messageType: String, + content: String, + viewedAt: Instant? = null, + savedAt: Instant = Instant.fromEpochSeconds(1_000), + ) = ChatMessage( + id = id, + senderPublicKey = sender, + isUserMessage = false, + giftWrapPayloadId = null, + marmotGroupEventId = groupEventId, + marmotInnerEventId = null, + chatRoomId = room, + content = content, + messageType = messageType, + savedAt = savedAt, + viewedAt = viewedAt, + ) + + @Test + fun `a group event read for the first time is written as it comes`() { + val fresh = line(messageType = "message", content = "hello") + + assertSame(fresh, ChatMessage.reconcileMarmotLine(fresh = fresh, existing = null)) + } + + @Test + fun `a recovered message replaces the placeholder rather than joining it`() { + val placeholder = line( + id = 42, + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ) + val recovered = line(messageType = "message", content = "hello") + + val row = ChatMessage.reconcileMarmotLine(fresh = recovered, existing = placeholder) + + assertEquals(42, row?.id, "reusing the row id is what makes this an update, not a second line") + assertEquals("hello", row?.content) + assertEquals("message", row?.messageType) + } + + @Test + fun `a placeholder never overwrites a line that says something`() { + val sent = line(id = 7, messageType = "message", content = "hello") + val placeholder = line( + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ) + + assertNull( + ChatMessage.reconcileMarmotLine(fresh = placeholder, existing = sent), + "a replay that cannot read our own message must leave its line alone", + ) + } + + @Test + fun `a commit still waiting may replace the placeholder it already wrote`() { + val pending = line( + id = 3, + messageType = ChatMessage.TYPE_PENDING_COMMIT, + content = "Pending Commit in epoch 4", + ) + val stillPending = line( + messageType = ChatMessage.TYPE_PENDING_COMMIT, + content = "Pending Commit in epoch 5", + ) + + val row = ChatMessage.reconcileMarmotLine(fresh = stillPending, existing = pending) + + assertEquals(3, row?.id, "one placeholder replacing another is still one line") + assertEquals("Pending Commit in epoch 5", row?.content) + } + + /** + * When a line was first seen, and when it was first stored, are facts about + * the reader rather than about the event. Reading the event again is not the + * message arriving again, so neither may be reset. + */ + @Test + fun `replacing a line keeps when it was first saved and seen`() { + val seenAt = Instant.fromEpochSeconds(2_000) + val storedAt = Instant.fromEpochSeconds(1_500) + + val placeholder = line( + id = 9, + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + viewedAt = seenAt, + savedAt = storedAt, + ) + val recovered = line( + messageType = "message", + content = "hello", + viewedAt = null, + savedAt = Instant.fromEpochSeconds(9_999), + ) + + val row = ChatMessage.reconcileMarmotLine(fresh = recovered, existing = placeholder) + + assertEquals(seenAt, row?.viewedAt, "a re-read must not mark a read message unread") + assertEquals(storedAt, row?.savedAt) + } + + /** + * The lines a replay is allowed to replace are exactly the ones that stand in + * for a message still to come. Adding a type to the transcript without adding + * it here silently makes those events unrecoverable; adding one here that + * reports something real makes them overwritable. + */ + @Test + fun `only the placeholder types count as unresolved`() { + assertEquals( + setOf(ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, ChatMessage.TYPE_PENDING_COMMIT), + ChatMessage.UNRESOLVED_MARMOT_TYPES, + ) + + listOf("message", "processedCommit", "proposalStaged", "artifact", "dialect") + .forEach { messageType -> + assertNull( + ChatMessage.reconcileMarmotLine( + fresh = line( + messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER, + content = "Undecryptable Message", + ), + existing = line(id = 1, messageType = messageType, content = "something"), + ), + "a $messageType line reports something and must survive a replay", + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt new file mode 100644 index 00000000..74cd329e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotCommitResultMappingTest.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.database.model + +import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Instant + +/** + * Where a commit's bytes land when the row that records it is written. + * + * `MarmotCommitResult` carries quartz's `CommitResult` payload fields verbatim -- + * `commitBytes`, `welcomeBytes`, `groupInfoBytes`, `framedCommitBytes`, + * `preCommitExporterSecret`, the same names and all of them `ByteArray`. A value + * taken from the wrong field of the right object therefore typechecks, and + * `framedCommitBytes = commitResult.preCommitExporterSecret` reached the database + * that way and sat there unnoticed: the column documented to hold a broadcastable + * `MlsMessage(PublicMessage(FramedContent(commit)))` envelope held 32 bytes of the + * group's pre-commit exporter secret instead. + * + * Nothing caught it because nothing read the column. The bytes that reached the + * relay come off the in-memory `CommitResult`, so the wire stayed correct while the + * record of it did not, and the row is written precisely so that the + * acknowledgement path in `DatabaseNostrRepository` can pick work back up later. A + * rebroadcast reading `framedCommitBytes` would have published noise the group + * decrypts, fails to parse, and drops -- silent, which is this subsystem's + * characteristic failure. + * + * So the routing is pinned here. Every payload gets a distinct, self-identifying + * value: a field that ends up in the wrong column names both halves of the mistake + * when it fails, rather than comparing equal by accident. + */ +class MarmotCommitResultMappingTest { + private val commitBytes = "raw-commit".encodeToByteArray() + private val framedCommitBytes = "framed-commit-envelope".encodeToByteArray() + private val welcomeBytes = "welcome".encodeToByteArray() + private val groupInfoBytes = "group-info".encodeToByteArray() + + /** Stands in for `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch. */ + private val preCommitExporterSecret = ByteArray(32) { 0x5E } + + private val commitEventId = "a".repeat(64) + private val chatRoomId = "b".repeat(64) + private val userPublicKey = "c".repeat(64) + private val peerKeyPackageEventId = "d".repeat(64) + private val createdAt = Instant.fromEpochSeconds(1_700_000_000) + + private fun commitResult( + framedCommitBytes: ByteArray = this.framedCommitBytes, + preCommitExporterSecret: ByteArray = this.preCommitExporterSecret, + ) = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + framedCommitBytes = framedCommitBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + private fun map(commitResult: CommitResult) = MarmotCommitResult.from( + commitEventId = commitEventId, + commitResult = commitResult, + chatRoomId = chatRoomId, + userPublicKey = userPublicKey, + peerKeyPackageEventId = peerKeyPackageEventId, + isOneMemberInitialGroupCreation = false, + createdAt = createdAt, + ) + + @Test + fun `every payload field lands in its own column`() { + val row = map(commitResult()) + + assertContentEquals(commitBytes, row.commitBytes, "commitBytes") + assertContentEquals(welcomeBytes, row.welcomeBytes, "welcomeBytes") + assertContentEquals(groupInfoBytes, row.groupInfoBytes, "groupInfoBytes") + assertContentEquals(framedCommitBytes, row.framedCommitBytes, "framedCommitBytes") + assertContentEquals( + preCommitExporterSecret, + row.preCommitExporterSecret, + "preCommitExporterSecret" + ) + } + + @Test + fun `the framed commit column never holds the exporter secret`() { + // The regression. Stated as the invariant rather than as an equality check, + // so it keeps holding for a CommitResult this test did not anticipate. + val row = map(commitResult()) + + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `a CommitResult that never framed its commit still stores a commit`() { + // quartz defaults framedCommitBytes to commitBytes, and the entity repeats that + // default. Whichever of the two a row ends up with, it must be a commit -- the + // fallback must not quietly become the secret either. + val unframed = CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = groupInfoBytes, + preCommitExporterSecret = preCommitExporterSecret, + ) + + val row = map(unframed) + + assertContentEquals(commitBytes, row.framedCommitBytes) + assertFalse( + row.framedCommitBytes.contentEquals(row.preCommitExporterSecret), + "the group's exporter secret was stored as the framed commit" + ) + } + + @Test + fun `the bookkeeping the acknowledgement path reads is carried through`() { + // DatabaseNostrRepository finds this row by the commit event's id and delivers the + // welcome using chatRoomId, userPublicKey and peerKeyPackageEventId. All four are + // supplied by the caller rather than the CommitResult, so they are checked here to + // keep the argument order of `from` honest -- every one of them is a 64-char hex + // string, and swapping two would otherwise typecheck as silently as the bug did. + val row = map(commitResult()) + + assertEquals(commitEventId, row.id) + assertEquals(chatRoomId, row.chatRoomId) + assertEquals(userPublicKey, row.userPublicKey) + assertEquals(peerKeyPackageEventId, row.peerKeyPackageEventId) + assertEquals(createdAt, row.createdAt) + assertFalse(row.isOneMemberInitialGroupCreation) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt new file mode 100644 index 00000000..509d803b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/MarmotGroupEventRoomFilterTest.kt @@ -0,0 +1,85 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * Which room a stored kind:445 belongs to. + * + * `NostrEventDao.getMarmotGroupNostrEventsByChatRoomId` finds a room's group + * events with `tags LIKE '%' || :chatRoomId || '%'`, because the tags are one + * serialised column and there is nothing better to match on. That is a prefilter + * and not a test: a group id can appear in a tag that is not the `h` tag, and an + * event matched that way belongs to a different room entirely. Feeding one of + * those to the replay would read another group's event against this group's MLS + * state. + * + * So the reindex confirms `groupId()` in Kotlin after the query. These pin the + * cases that confirmation is there for. + */ +class MarmotGroupEventRoomFilterTest { + + private val room = "a".repeat(64) + private val otherRoom = "d".repeat(64) + private val ephemeralSender = "e".repeat(64) + + private fun storedGroupEvent( + tags: Array>, + kind: Int = 445, + ) = NostrEvent( + id = "1".repeat(64), + pubKey = ephemeralSender, + kind = kind, + tags = tags, + content = "bm9uY2UrY2lwaGVydGV4dA==", + sig = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + @Test + fun `the h tag is what names the room`() { + val stored = storedGroupEvent(arrayOf(arrayOf("h", room))) + + assertEquals(room, stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId()) + } + + /** + * The case the `LIKE` cannot tell apart: this event is another room's, and + * only mentions ours in a tag that says nothing about routing. + */ + @Test + fun `an event mentioning the room outside its h tag belongs to the other room`() { + val stored = storedGroupEvent( + arrayOf( + arrayOf("h", otherRoom), + arrayOf("q", room), + ) + ) + + val groupId = stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId() + + assertEquals(otherRoom, groupId) + assertNotEquals(room, groupId, "the LIKE would match this event; groupId() is what rejects it") + } + + @Test + fun `an event with no h tag names no room`() { + val stored = storedGroupEvent(arrayOf(arrayOf("q", room))) + + assertNull(stored.toGroupEvent(userPublicKey = ephemeralSender)?.groupId()) + } + + /** + * Only kind:445 is a group event. The query pins the kind in SQL, and this is + * the other half of that: nothing else can be read as one by mistake. + */ + @Test + fun `an event of another kind is not a group event`() { + val stored = storedGroupEvent(tags = arrayOf(arrayOf("h", room)), kind = 9) + + assertNull(stored.toGroupEvent(userPublicKey = ephemeralSender)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt index ea0437ab..eb5572f0 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -66,7 +66,8 @@ class RumorIdAgreementTest { url = "example.com", visibility = "private", license = "cc", - dialectId = other + dialectId = other, + versionLabel = "1.0" ) val entity = MantraArtifact.fromArtifactEventTemplate( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt new file mode 100644 index 00000000..4086c3d1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt @@ -0,0 +1,131 @@ +package press.mantra.compose.database.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * When a request line in a transcript stops asking for something. + * + * The transcript renders a request with a tint and a "Review" affordance, and + * that is a promise: tapping it leads to a decision still there to be made. + * Keeping the promise means knowing when the decision has gone, and the rows are + * all there is to know it from -- a line rendered days later has no session to + * ask, and the room may have signed several things since. + * + * Two ways for a request to be over, and they are not the same. Answering it + * leaves a line of the reader's own and earns the tick. A session ending + * underneath it leaves nothing of theirs at all: a member who declined published + * nothing, and a quorum that signed without them wanted nothing. Both must drop + * the summons; neither may claim the member signed. + */ +class TranscriptRequestStateTest { + private val user = "u".repeat(64) + private val other = "o".repeat(64) + + private var lastId = 0L + + /** One transcript row, with only the four fields either rule reads. */ + private fun line( + type: String, + at: Long, + sender: String = user + ) = ChatMessage( + id = ++lastId, + senderPublicKey = sender, + isUserMessage = sender == user, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = "room", + content = "", + messageType = type, + createdAt = Instant.fromEpochSeconds(at) + ) + + @Test + fun `a signing request nobody has acted on is still asking`() { + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request) + + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `publishing the nonce answers the request that asked for it`() { + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11)) + + assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `somebody else's nonce answers nothing`() { + // The fulfilment has to be this device's own: a transcript is full of other + // members taking the step this reader has yet to take. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other)) + + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `declining settles the request without claiming it was signed`() { + // Declining publishes nothing, so there is no fulfilment to find. The + // failure the refusal writes is the only trace, and it has to be enough -- + // otherwise the line goes on offering a decision already made. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11)) + + assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript)) + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `a group that signs without this member settles their request`() { + // A t-of-n key does not need everybody. Nothing of this member's is in the + // signature and nothing of theirs was ever published, so answered stays + // empty -- but there is no longer anything for them to decide. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10) + val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other)) + + assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript)) + assertEquals(emptySet(), ChatMessage.answeredRequests(transcript)) + } + + @Test + fun `an earlier session's ending does not close a later request`() { + // Rooms sign more than once, and the previous session's last line sits + // above this one's first. + val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20) + val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request) + + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `a ceremony step is settled by nothing`() { + // Signing is the one thing a member can refuse, so it is the only place a + // request can be over without them having answered it. A ceremony step is + // either taken or still waited on, and reading either ending as the end of + // one would drop a summons the ritual is still stalled on. + val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10) + val transcript = listOf( + request, + line(ChatMessage.TYPE_FROST_FAILED, at = 11), + line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other) + ) + + assertEquals(emptySet(), ChatMessage.settledRequests(transcript)) + } + + @Test + fun `each ceremony step is answered only by its own`() { + val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10) + val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12) + val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne) + + assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 0d4fc4c5..ca9d3690 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.managers +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -17,8 +18,10 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Instant import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.FrostSigningEvents @@ -217,6 +220,39 @@ class FrostSigningSessionTest { signerIds = signerIds ) + @Test + fun `a session waits on its owner until they answer`() { + val open = session(signerId = 2, signerIds = null) + + assertTrue(FrostSigningManager.isAwaitingApproval(open)) + assertFalse( + FrostSigningManager.isAwaitingApproval( + open.copy(signApprovedAt = Instant.fromEpochSeconds(1)) + ) + ) + } + + @Test + fun `a session that has settled asks its owner nothing`() { + val open = session(signerId = 2, signerIds = null) + + assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE))) + assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED))) + } + + @Test + fun `a signature the group already made asks its owner nothing either`() { + // A t-of-n key does not need everybody, so a quorum can finish while one + // member's phone is still in a pocket. The session stays at its opening + // stage on their device until it next advances, and offering them the + // decision in that window offers two bad answers: a nonce nobody is + // waiting for, or a refusal that abandons a signature that exists. + val signedWithoutThem = session(signerId = 2, signerIds = "0,1") + .copy(signature = "a".repeat(128)) + + assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem)) + } + @Test fun `a member left out of the signer set is not a signer`() { assertTrue(session(signerId = 1, signerIds = "0,1").isSigner()) @@ -276,3 +312,175 @@ class FrostSigningSessionTest { ) } } + +/** + * What a device needs on its row to finish a session it never took part in. + * + * `FrostSigningManager.advance` completes on an arrived signature ahead of the + * approval gate, and that hoist rests on one claim: closing a session needs + * nothing secret and nothing the member would have had to publish. Were it + * false -- were the aggregated nonce, the signer set or a share needed to check + * the result -- the gate would have to stay where it was, and a member the + * quorum did not need would be stuck being asked to sign something already + * signed. + * + * So the claim is spelled out here against a real 2-of-3 signature, from the + * row of the member who was left out of it. + */ +class FrostSigningCompletionTest { + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("2decade0000000000000000000000000000000000000000000000000000000b2") + ), + nParticipants = 3, + threshold = 2 + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity, exactly as `unsignedEventOf` derives it. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + /** The event the group is asked to sign, built the way the manager builds it. */ + private val unsignedEvent = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "a dialect the group agreed on" + ), + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "a dialect the group agreed on", + sig = "" + ) + + /** A real signature from members 0 and 1. Member 2 is not in it and never was. */ + private val signature: String = run { + val message = ByteVector(unsignedEvent.id.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("c".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val session = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = 3, + threshold = 2, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + session.aggregateSigs(partials).right!!.toHex() + } + + /** + * Member 2's row, as it stands when the signature reaches them: they never + * approved, so nothing of theirs was ever published, and the coordinator + * never named them. Every column the completion path reads is here; the ones + * it must not need are deliberately left null. + */ + private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "k".repeat(64), + threshold = 2, + participantCount = 3, + signerId = 2, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64), + aggregatedNonce = null, + signerIds = null, + signature = signature, + signApprovedAt = null + ) + + @Test + fun `a member who never took part can still check what the group signed`() { + val session = leftOutMemberSession(signature) + val signed = FrostSigningManager.signedEvent(session)!! + + assertTrue( + Nip01Crypto.verify( + signature = signed.sig.hexToByteArray(), + hash = session.eventId.hexToByteArray(), + pubKey = signed.pubKey.hexToByteArray() + ), + "completing must need only the row: the event, its id and the signature" + ) + } + + @Test + fun `the finished event is the one that was proposed, with a signature on it`() { + // Not rebuilt and not rehashed: the id a session is pinned to is the id + // the signature is over, so anything that changed here would produce an + // event whose signature verifies against nothing. + val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!! + + assertEquals(unsignedEvent.id, signed.id) + assertEquals(unsignedEvent.pubKey, signed.pubKey) + assertEquals(unsignedEvent.createdAt, signed.createdAt) + assertEquals(unsignedEvent.kind, signed.kind) + assertEquals(unsignedEvent.content, signed.content) + assertEquals(signature, signed.sig) + } + + @Test + fun `there is no finished event until the signature arrives`() { + assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession())) + } + + @Test + fun `the arrived signature is what stops the session asking`() { + // The pair that matters to the screen and the transcript: the same row, + // before and after the group finished without this member. + assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession())) + assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature))) + } + + @Test + fun `a signature over a different event is refused`() { + // What the check is for. A coordinator passing off something else must not + // get it applied and announced as the group's, and the row is all there is + // to catch it with. + val other = leftOutMemberSession(signature).copy( + eventId = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = "something else entirely" + ) + ) + + val signed = FrostSigningManager.signedEvent(other)!! + + assertFalse( + Nip01Crypto.verify( + signature = signed.sig.hexToByteArray(), + hash = other.eventId.hexToByteArray(), + pubKey = signed.pubKey.hexToByteArray() + ) + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt new file mode 100644 index 00000000..854d78d6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -0,0 +1,245 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.frost.GroupKeyStateEvent + +/** + * What a room's key-state announcement is allowed to convince a member of. + * + * The announcement is made by the coordinator, and the coordinator is untrusted + * by construction -- the same assumption every other part of the ceremony is + * written under. So the interesting cases here are all the ones where a state + * is *wrong*: a member who acts on a state naming a key their room was not made + * from signs with a share that cannot aggregate, or worse, treats a key the + * group does not hold as the key the group holds. + * + * `GroupKeyStateManager` needs a database and so cannot be stood up here. What + * can be is the check it defers to, which is where the whole trust model lives. + */ +class GroupKeyStateTest { + /** Stands in for a ceremony's output. Any valid point will do. */ + private val thresholdPublicKey = PrivateKey( + Hex.decode("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ).publicKey().value.toHex() + + /** A second group's key, for the states that name the wrong one. */ + private val otherKey = PrivateKey( + Hex.decode("2bada550000000000000000000000000000000000000000000000000000000b2") + ).publicKey().value.toHex() + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + + private fun state( + chatRoomId: String = this.chatRoomId, + thresholdPublicKey: String = this.thresholdPublicKey, + derivationPath: String = SharedKeyDerivation.formatPath(path) + ) = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + thresholdPublicKey = thresholdPublicKey, + derivationPath = derivationPath, + announcedBy = "c00rd1na70r", + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + @Test + fun `a state describing the room it was announced in verifies`() { + assertTrue(state().verifies()) + } + + @Test + fun `a state naming another group's key does not verify`() { + // The attack this is here for: a coordinator pointing the room at a key + // the group never made, so that everything signed in it is signed by + // whoever holds that key instead. + assertFalse(state(thresholdPublicKey = otherKey).verifies()) + } + + @Test + fun `a state naming the right key at the wrong path does not verify`() { + // The path is half the derivation, so getting it wrong reaches a + // different room just as surely as getting the key wrong does. + assertFalse(state(derivationPath = "m/9420/0/1").verifies()) + } + + @Test + fun `a state for one room does not verify against another`() { + assertFalse(state(chatRoomId = otherKey).verifies()) + } + + @Test + fun `a state carrying an unwalkable path does not verify`() { + // Hardened derivation needs the parent private key, which nobody in a + // threshold group has, so a hardened path was never walked to anything. + assertFalse(state(derivationPath = "m/9420'/0/0").verifies()) + assertFalse(state(derivationPath = "9420/0/0").verifies()) + assertFalse(state(derivationPath = "").verifies()) + } + + @Test + fun `the tags a state is announced on read back as they were written`() { + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path + ) + + assertEquals(chatRoomId, GroupKeyStateEvent.parseChatRoomId(tags)) + assertEquals("ceremony-1", GroupKeyStateEvent.parseDkgSessionId(tags)) + assertEquals(path, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a threshold key is only read out of content that is one`() { + assertEquals(thresholdPublicKey, GroupKeyStateEvent.parseThresholdPublicKey(thresholdPublicKey)) + + // 32 bytes is an x-only key, not the 33-byte compressed point a ceremony + // reports; anything else is not a key at all. + assertNull(GroupKeyStateEvent.parseThresholdPublicKey(chatRoomId)) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("not a key")) + assertNull(GroupKeyStateEvent.parseThresholdPublicKey("z".repeat(66))) + } + + @Test + fun `a path survives the trip through a tag`() { + val deep = listOf(9420L, 7L, 0L, 1L) + val tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", deep) + + assertEquals(deep, GroupKeyStateEvent.parsePath(tags)) + } + + @Test + fun `a room derived at a path other than the default still verifies at it`() { + // The reason the path is announced rather than assumed: a lookup that + // hardcodes MARMOT_ADMIN_GROUP_PATH cannot find this room at all. + val sibling = listOf(9420L, 0L, 1L) + val siblingRoom = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, sibling) + + assertTrue( + state( + chatRoomId = siblingRoom, + derivationPath = SharedKeyDerivation.formatPath(sibling) + ).verifies() + ) + } + + // ---- The announcement as it actually arrives ------------------------- + // + // Everything above checks the verdict on a state already assembled. These + // check the assembling: a real Event, with the tags and content an + // announcement is carried on, through the function the inbound path calls. + + private fun announcement( + content: String = thresholdPublicKey, + tags: Array> = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path), + pubKey: String = "c00rd1na70r", + createdAt: Long = 1_700_000_000 + ) = Event( + id = "an-id", + pubKey = pubKey, + createdAt = createdAt, + kind = GroupKeyStateEvent.KIND, + tags = tags, + content = content, + sig = "" + ) + + @Test + fun `an announcement of the room it arrives in is taken`() { + val state = GroupKeyStateManager.stateFrom(chatRoomId, announcement()) + + assertEquals(chatRoomId, state?.chatRoomId) + assertEquals("ceremony-1", state?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.derivationPath) + // Attribution and ordering come off the event, not off the clock. + assertEquals("c00rd1na70r", state?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + } + + @Test + fun `an announcement naming another group's key is dropped`() { + // The one that matters: a coordinator pointing the room at a key the + // group never made. Everything else here is malformed input; this is + // well-formed input that lies. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = otherKey))) + } + + @Test + fun `an announcement addressed to another room is dropped`() { + val elsewhere = GroupKeyStateEvent.assembleTags(otherKey, "ceremony-1", path) + + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = elsewhere))) + } + + @Test + fun `an announcement missing any of what it has to say is dropped`() { + val full = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) + + // No ceremony to reach a share through. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_key" }.toTypedArray()) + ) + ) + // No path, so nothing to rebuild a TweakCache from. + assertNull( + GroupKeyStateManager.stateFrom( + chatRoomId, + announcement(tags = full.filterNot { it[0] == "frost_path" }.toTypedArray()) + ) + ) + // No key. + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, announcement(content = ""))) + } + + @Test + fun `an announcement carrying no d tag is judged on its derivation alone`() { + // The d tag is a convenience for a reader holding the event on its own. + // Dropping it loses nothing that matters, because the room it arrived in + // plus the derivation still settle the question. + val undirected = arrayOf( + arrayOf("frost_key", "ceremony-1"), + arrayOf("frost_path", "m/9420/0/0") + ) + + assertEquals( + chatRoomId, + GroupKeyStateManager.stateFrom(chatRoomId, announcement(tags = undirected))?.chatRoomId + ) + } + + @Test + fun `a path index wider than a uint32 is not a path`() { + // tweakScalar serialises an index as its low four bytes, so m/4294967296 + // would otherwise walk exactly where m/0 does -- one room, two spellings, + // both verifying. Rejected at the parser so formatPath stays a round trip. + assertNull(SharedKeyDerivation.parsePathString("m/4294967296/0/0")) + assertNull(SharedKeyDerivation.parsePathString("m/-1/0/0")) + + assertEquals(listOf(4294967295L), SharedKeyDerivation.parsePathString("m/4294967295")) + assertEquals(listOf(0L), SharedKeyDerivation.parsePathString("m/0")) + } + + @Test + fun `a state whose path indices are out of range does not verify`() { + // Reachable only by constructing the row directly; the parser above + // refuses to build one. Checked because verifies() is what everything + // else defers to, and it should not be the thing that trusts its input. + assertFalse(state(derivationPath = "m/4294967296/0/0").verifies()) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt new file mode 100644 index 00000000..8761f337 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt @@ -0,0 +1,175 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GROUP_SUB_ID_PREFIX +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.giftWrapFilter +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupChunks +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupFilter +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupIdsFrom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionIndex +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionKey +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.endsLiveSubscription +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LiveSubscriptionPlanTest { + + private val me = "a".repeat(64) + + // --- filters ---------------------------------------------------------------------- + + /** + * The one that will look like an oversight to whoever reads it next. + * + * NIP-59 randomizes a gift wrap's `created_at` into the past, and our own outbound path + * stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap published this second can + * carry a timestamp two days old. A `since` anywhere near the present silently drops a + * large share of genuinely new messages, and the symptom is "some DMs just never arrive": + * no error, no log, nothing to grep for. + */ + @Test + fun `the gift wrap filter carries no since`() { + assertNull(giftWrapFilter(me).since) + } + + @Test + fun `the gift wrap filter asks for wraps addressed to us`() { + val filter = giftWrapFilter(me) + + assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds) + assertEquals(mapOf("p" to listOf(me)), filter.tags) + assertEquals(100, filter.limit) + } + + /** + * `limit` is not a cap on the subscription — NIP-01 scopes it to the stored events a relay + * sends before EOSE, explicitly not to the stream after it. It bounds what a reconnect + * costs; it does not bound what arrives live. + */ + @Test + fun `the group filter asks for the given groups, and also carries no since`() { + val filter = groupFilter(listOf("group-a", "group-b")) + + assertEquals(listOf(GroupEvent.KIND), filter.kinds) + assertEquals(mapOf("h" to listOf("group-a", "group-b")), filter.tags) + assertEquals(500, filter.limit) + assertNull(filter.since) + } + + // --- which groups are watched ----------------------------------------------------- + + @Test + fun `only MLS rooms are watched`() { + val rooms = listOf( + room(id = "mls", mlsGroupState = "state"), + // A NIP-17 room has no group state; its messages arrive as gift wraps instead, so + // h-tag subscribing to it would ask for events that do not exist. + room(id = "nip17", mlsGroupState = null), + ) + + assertEquals(listOf("mls"), groupIdsFrom(rooms)) + } + + /** + * A room we left keeps its history locally and must stop pulling new messages. Getting + * this wrong is not merely wasteful: it means still receiving from a group we are no + * longer a member of. + */ + @Test + fun `rooms we have left or deleted are not watched`() { + val rooms = listOf( + room(id = "here", mlsGroupState = "state"), + room(id = "left", mlsGroupState = "state", leftGroupAt = Instant.fromEpochSeconds(10)), + room(id = "gone", mlsGroupState = "state", deletedAt = Instant.fromEpochSeconds(10)), + ) + + assertEquals(listOf("here"), groupIdsFrom(rooms)) + } + + /** + * Sorted, so the same membership in a different row order is the same value and the + * `distinctUntilChanged` upstream of the reconcile does not re-send a filter that has not + * actually changed. + */ + @Test + fun `group ids come out sorted`() { + val rooms = listOf("c", "a", "b").map { room(id = it, mlsGroupState = "state") } + + assertEquals(listOf("a", "b", "c"), groupIdsFrom(rooms)) + } + + // --- chunking and subscription ids ------------------------------------------------- + + @Test + fun `groups are chunked to bound the size of one filter's tag array`() { + val chunks = groupChunks((1..250).map { "group-$it" }) + + assertEquals(3, chunks.size) + assertEquals(listOf(100, 100, 50), chunks.map { it.size }) + } + + @Test + fun `no groups means no subscriptions`() { + assertTrue(groupChunks(emptyList()).isEmpty()) + } + + @Test + fun `a subscription key round-trips back to its chunk index`() { + val key = subscriptionKey(relayUrl = "wss://relay.example.com", subId = groupSubId(3)) + + assertEquals("wss://relay.example.com|${GROUP_SUB_ID_PREFIX}3", key) + assertEquals(3, subscriptionIndex(key)) + } + + /** + * `subscriptionIndex` decides which subscriptions get closed when membership shrinks — + * anything at or past the new chunk count goes. Unparseable therefore has to read as "past + * the end", so a key nobody recognises is closed rather than kept open forever. + */ + @Test + fun `an unrecognisable key sorts past the end so it gets closed`() { + assertEquals(Int.MAX_VALUE, subscriptionIndex("wss://relay.example.com|$GIFT_WRAP_SUB_ID")) + assertEquals(Int.MAX_VALUE, subscriptionIndex("nonsense")) + } + + // --- what ends a live subscription ------------------------------------------------- + + @Test + fun `EOSE does not end a live subscription, CLOSED does`() { + assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription()) + assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription()) + assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription()) + + assertTrue( + NostrIncomingMessage.ClosedMessage(subscriptionId = "s", message = "rate-limited") + .endsLiveSubscription() + ) + } + + private fun room( + id: String, + mlsGroupState: String?, + leftGroupAt: Instant? = null, + deletedAt: Instant? = null, + ) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = mlsGroupState, + leftGroupAt = leftGroupAt, + deletedAt = deletedAt, + ) + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt new file mode 100644 index 00000000..0eb46c50 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionReconcileTest.kt @@ -0,0 +1,428 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID +import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId +import press.mantra.compose.network.relays.LiveSubscriptionTransport +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * The requirement this whole change exists to meet: the group subscription has to follow group + * membership, without anyone remembering to call a subscribe function when a group is joined. + * + * The failure this guards against is entirely silent. A group whose id never makes it into the + * `#h` filter is not an error anywhere — it is a conversation that simply never delivers, on a + * screen that looks exactly like an empty one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LiveSubscriptionReconcileTest { + + private val keyPair = KeyPair() + private val me = keyPair.pubKey.toHexString() + + @Test + fun `opens a group subscription for the groups we are in`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b"))) + val running = start(transport, rooms) + + settle() + + val opened = transport.opened.single { it.reqCommand.subId == groupSubId(0) } + assertEquals( + mapOf("h" to listOf("group-a", "group-b")), + opened.reqCommand.filters.single().tags, + ) + + running.cancelAndJoin() + } + + /** + * The loop that closes: a Welcome arrives on the gift wrap subscription, a ChatRoom row is + * written, the room list re-emits, and the filter widens — with no chat screen involved. + * + * It has to widen *in place*. Closing and re-opening would drop the live tail of every + * group already in that chunk for as long as the round trip takes, so joining one group + * would briefly stop delivery on all the others. + */ + @Test + fun `joining a group widens the filter without reopening the subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + val opensBefore = transport.opened.count { it.reqCommand.subId == groupSubId(0) } + + rooms.value = listOf(room("group-a"), room("group-new")) + settle() + + val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) } + assertEquals( + mapOf("h" to listOf("group-a", "group-new")), + updated.reqCommand.filters.single().tags, + ) + assertEquals( + opensBefore, + transport.opened.count { it.reqCommand.subId == groupSubId(0) }, + "widening must not re-open the subscription", + ) + + running.cancelAndJoin() + } + + @Test + fun `leaving a group drops it from the filter`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b"))) + val running = start(transport, rooms) + settle() + + rooms.value = listOf(room("group-a"), room("group-b", leftGroupAt = Instant.fromEpochSeconds(1))) + settle() + + val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) } + assertEquals(mapOf("h" to listOf("group-a")), updated.reqCommand.filters.single().tags) + + running.cancelAndJoin() + } + + @Test + fun `leaving every group closes the subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + rooms.value = emptyList() + settle() + + assertTrue(transport.closed.any { it.subId == groupSubId(0) }) + + running.cancelAndJoin() + } + + /** + * Membership churn inside the debounce window must collapse. Joining a group writes the + * room, its participants and placeholder profiles in quick succession, each of which + * re-emits the list — so without this a single join re-sends the filter several times. + */ + @Test + fun `rapid membership changes collapse into one update`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("group-a"))) + val running = start(transport, rooms) + settle() + + val updatesBefore = transport.updated.size + + rooms.value = listOf(room("group-a"), room("group-b")) + advanceTimeBy(100) + rooms.value = listOf(room("group-a"), room("group-b"), room("group-c")) + advanceTimeBy(100) + rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"), room("group-d")) + settle() + + assertEquals(1, transport.updated.size - updatesBefore, "expected one update, not three") + + running.cancelAndJoin() + } + + @Test + fun `a NIP-17 room never becomes a group subscription`() = runTest { + val transport = FakeTransport() + val rooms = MutableStateFlow(listOf(room("dm-room", mlsGroupState = null))) + val running = start(transport, rooms) + settle() + + assertTrue(transport.opened.none { it.reqCommand.subId.startsWith("live-groups-") }) + + running.cancelAndJoin() + } + + // --- what arrives on a subscription ------------------------------------------------- + + @Test + fun `events are stored against the relay they arrived from`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(emptyList()), nostr) + settle() + + val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + subscription.messages.emit( + NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("ev-1")) + ) + settle() + + val saved = nostr.saved.single() + assertEquals("ev-1", saved.first) + assertEquals(subscription.relayUrl, saved.second) + + running.cancelAndJoin() + } + + /** EOSE is a marker, not an end: what follows it is the whole point. */ + @Test + fun `an event after EOSE is still stored`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(emptyList()), nostr) + settle() + + val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + subscription.messages.emit(NostrIncomingMessage.EoseMessage(subscriptionId = GIFT_WRAP_SUB_ID)) + subscription.messages.emit( + NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("after-eose")) + ) + settle() + + assertEquals(listOf("after-eose"), nostr.saved.map { it.first }) + + running.cancelAndJoin() + } + + @Test + fun `a relay that closes the subscription gets it re-opened`() = runTest { + val transport = FakeTransport() + val running = start(transport, MutableStateFlow(emptyList())) + settle() + + val first = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID } + first.messages.emit( + NostrIncomingMessage.ClosedMessage(subscriptionId = GIFT_WRAP_SUB_ID, message = "shutting down") + ) + + settle(1.seconds) + assertEquals( + 1, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "should still be inside the back-off, not hammering the relay", + ) + + settle(30.seconds) + assertEquals( + 2, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "a CLOSED subscription should have been re-opened once the back-off elapsed", + ) + + running.cancelAndJoin() + } + + /** + * Back-pressure is the one refusal that must NOT be answered promptly — opening another + * subscription is exactly what the relay just asked us to stop doing. + */ + @Test + fun `a rate-limited close waits far longer before re-opening`() = runTest { + val transport = FakeTransport() + val running = start(transport, MutableStateFlow(emptyList())) + settle() + + transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }.messages.emit( + NostrIncomingMessage.ClosedMessage( + subscriptionId = GIFT_WRAP_SUB_ID, + message = "rate-limited: too many concurrent REQs", + ) + ) + settle(30.seconds) + assertEquals( + 1, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "must not re-open within the window a plain failure would have", + ) + + // ...but it is a back-off, not a give-up. + settle(5.minutes) + assertEquals( + 2, + transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID }, + "the subscription should come back once the relay has had its breathing room", + ) + + running.cancelAndJoin() + } + + // --- lifecycle ----------------------------------------------------------------------- + + @Test + fun `backgrounding closes the subscriptions and foregrounding rebuilds them`() = runTest { + val transport = FakeTransport() + val foreground = MutableStateFlow(true) + val running = start(transport, MutableStateFlow(emptyList()), isForeground = foreground) + settle() + + val openedWhileForeground = transport.opened.size + assertTrue(openedWhileForeground > 0) + + foreground.value = false + settle() + assertTrue(transport.closed.any { it.subId == GIFT_WRAP_SUB_ID }, "should have said goodbye") + + foreground.value = true + settle() + assertTrue(transport.opened.size > openedWhileForeground, "should have re-subscribed") + + running.cancelAndJoin() + } + + /** + * A live subscription covers the window we are connected for; it cannot answer for the gap + * we were away. That is negentropy's job, and returning to the foreground is precisely + * when it needs asking. + */ + @Test + fun `foregrounding reconnects and queues a catch-up reconciliation`() = runTest { + val transport = FakeTransport() + val nostr = RecordingNostrRepository() + val running = start(transport, MutableStateFlow(listOf(room("group-a"))), nostr) + settle() + + assertEquals(1, transport.reconnects, "the socket must not be trusted after a gap") + + val purposes = nostr.queued.map { it.purpose }.toSet() + assertTrue("chat" in purposes, "gift wraps should be reconciled") + assertTrue("mlsMessages" in purposes, "group events should be reconciled") + + running.cancelAndJoin() + } + + // --- harness ------------------------------------------------------------------------- + + /** + * Advances virtual time by exactly [duration] and runs what that makes due. + * + * Deliberately not `advanceUntilIdle()`: that runs until nothing is scheduled at all, + * which means it fast-forwards through *any* pending delay — including the five-minute + * back-off this suite needs to assert has NOT elapsed. A test that cannot tell "waited" + * from "did not wait" cannot test a back-off at all. + */ + private fun kotlinx.coroutines.test.TestScope.settle(duration: Duration = 1.seconds) { + advanceTimeBy(duration) + runCurrent() + } + + private fun kotlinx.coroutines.test.TestScope.start( + transport: FakeTransport, + rooms: MutableStateFlow>, + nostr: RecordingNostrRepository = RecordingNostrRepository(), + isForeground: MutableStateFlow = MutableStateFlow(true), + ) = launch { + LiveSubscriptionManager( + relaysSocketManager = transport, + nostrRepository = nostr, + chatRepository = FakeChatRepository(rooms), + isForeground = isForeground, + ).observe(keyPair) + } + + private fun room( + id: String, + mlsGroupState: String? = "state", + leftGroupAt: Instant? = null, + ) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = mlsGroupState, + leftGroupAt = leftGroupAt, + ) + ) + + private fun event(id: String) = NostrEvent( + id = id, + pubKey = me, + createdAt = Instant.fromEpochSeconds(1_000), + kind = 1059, + tags = emptyArray(), + content = "", + sig = "", + ) +} + +private class OpenedSubscription(val reqCommand: ReqCmd, val relayUrl: String) { + val messages = MutableSharedFlow(extraBufferCapacity = 32) +} + +private class FakeTransport : LiveSubscriptionTransport { + val opened = mutableListOf() + val updated = mutableListOf() + val closed = mutableListOf() + var reconnects = 0 + + data class ClosedSubscription(val subId: String, val relayUrl: String) + + override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow = + OpenedSubscription(reqCommand, relayUrl).also { opened += it }.messages.asSharedFlow() + + override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) { + updated += OpenedSubscription(reqCommand, relayUrl) + } + + override suspend fun closeLiveSubscription(subId: String, relayUrl: String) { + closed += ClosedSubscription(subId, relayUrl) + } + + override suspend fun reconnectAll() { + reconnects++ + } +} + +private class FakeChatRepository( + private val rooms: StateFlow>, +) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> = rooms + + override suspend fun getChatRoomListByPublicKey(publicKey: String): List = rooms.value +} + +private class RecordingNostrRepository : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + /** (event id, relay it arrived from) */ + val saved = mutableListOf>() + val queued = mutableListOf() + + override suspend fun saveNostrEvent( + nostrEvent: NostrEvent, + relayURL: String, + synchronizationRelayURLs: List, + level: Int, + activeKeyPair: KeyPair, + ) { + saved += nostrEvent.id to relayURL + } + + override suspend fun queueNegentropySynchronizeRequest( + negentropySynchronizeRequests: List, + ) { + queued += negentropySynchronizeRequests + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt new file mode 100644 index 00000000..f6059a92 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -0,0 +1,82 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The one hole deliberately left in MIP-03's author check, and its edges. + * + * MIP-03 requires an inner application event's pubkey to equal the MLS sender's credential + * identity, which is what stops a member minting events attributed to somebody else. A + * gift wrap cannot satisfy it -- its pubkey is a throwaway key that names nobody -- so + * kind:1059 is exempt, and the authorship claim moves to the signed seal inside. + * + * The exemption is the most dangerous line in this feature. Widened to another kind, or + * stripped of its kind guard, it hands every member of every group the ability to publish + * events as anybody, and nothing else in the pipeline would notice. These tests exist so + * that widening it fails here rather than in a group. + */ +class MarmotMip03CarveOutTest { + private val sender = "a".repeat(64) + private val somebodyElse = "b".repeat(64) + private val throwaway = "c".repeat(64) + + @Test + fun `an ordinary message from its own author is accepted`() { + assertNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, sender)) + } + + @Test + fun `an ordinary message claiming somebody else is rejected`() { + val reason = MarmotInboundManager.mip03Rejection(ChatEvent.KIND, somebodyElse, sender) + + assertNotNull(reason) + assertTrue(reason.startsWith("MIP-03"), "expected the MIP-03 rejection, got: $reason") + } + + @Test + fun `a gift wrap is accepted despite naming nobody`() { + // The carve-out. The wrap's pubkey is a throwaway key and matches no member, which + // is the point of it; what authenticates the sender is the MLS frame, and what + // authenticates the author is the seal inside. See MarmotDirectMessage.classify. + assertNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, sender)) + } + + @Test + fun `the carve-out does not extend to any other kind`() { + // Every kind carried as an application payload today, plus the seal and rumor + // kinds a gift wrap contains -- none of which should ever arrive unwrapped, and + // all of which would be a way to launder an author if the guard were widened. + val kinds = listOf( + ChatEvent.KIND, + ChatMessageEvent.KIND, + SealedRumorEvent.KIND, + GiftWrapEvent.KIND - 1, + GiftWrapEvent.KIND + 1, + 0, + 1, + ) + + kinds.forEach { kind -> + assertNotNull( + MarmotInboundManager.mip03Rejection(kind, somebodyElse, sender), + "kind $kind was allowed to claim an author it does not own", + ) + } + } + + @Test + fun `a sender with no credential identity is rejected, wrap or not`() { + // Required rather than merely compared: with no identity there is nothing to + // attribute a line to, and for a gift wrap there is nothing to check the seal + // against either. Both kinds must fail, not just the one that does a comparison. + assertNotNull(MarmotInboundManager.mip03Rejection(ChatEvent.KIND, sender, null)) + assertNotNull(MarmotInboundManager.mip03Rejection(GiftWrapEvent.KIND, throwaway, null)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt new file mode 100644 index 00000000..51a0f4b0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotReindexSweepTest.kt @@ -0,0 +1,173 @@ +package press.mantra.compose.managers + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The stopping rule a reindex sweep runs on. + * + * A replay is worth doing at all because the events depend on each other: the + * commit nobody could apply is what the room's later messages were waiting on. + * That is also what makes one ordered pass insufficient, and what makes "keep + * going" dangerous — a room full of events whose keys are gone would be swept + * forever. Both halves of that are pinned here. + * + * The sweep never sees a database. What counts as recovered is whatever the + * `isUnresolved` probe says afterwards, so these fakes are the real contract and + * not a stand-in for one. + */ +class MarmotReindexSweepTest { + + /** + * A backlog where each event is unlocked by the one before it, which is the + * shape a chain of out-of-order commits arrives in. + * + * [unlockedBy] is the event that has to be replayed successfully first; + * null means it can be read straight away. + */ + private class Backlog(unlocks: Map) { + private val unlockedBy = unlocks + private val resolved = mutableSetOf() + + /** Every event replayed, in order, across every pass. */ + val replayed = mutableListOf() + + suspend fun replay(id: String) { + replayed.add(id) + val blocker = unlockedBy.getValue(id) + if (blocker == null || blocker in resolved) { + resolved.add(id) + } + } + + suspend fun isUnresolved(id: String): Boolean = id !in resolved + } + + @Test + fun `a chain of events unlocking each other is read in as many passes as it takes`() = runTest { + // c waits on b, b waits on a. One pass in this order recovers only a. + val backlog = Backlog(mapOf("c" to "b", "b" to "a", "a" to null)) + + val report = MarmotReindexSweep.run( + stored = 10, + unresolved = listOf("c", "b", "a"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(3, report.recovered, "every event in the chain should have been recovered") + assertEquals(0, report.failed) + assertEquals(3, report.unresolved, "unresolved is what the sweep started with") + assertEquals(10, report.stored, "stored is reported as handed in") + } + + @Test + fun `an event that can never be read stops the sweep after one pass`() = runTest { + // Nothing unlocks these: the keys are gone, replaying achieves nothing. + val backlog = Backlog(mapOf("a" to "never", "b" to "never")) + + val report = MarmotReindexSweep.run( + stored = 2, + unresolved = listOf("a", "b"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(listOf("a", "b"), backlog.replayed, "a pass that recovers nothing must not repeat") + assertEquals(0, report.recovered) + assertEquals(2, report.failed) + } + + @Test + fun `events that can be read are still recovered alongside ones that cannot`() = runTest { + val backlog = Backlog(mapOf("stuck" to "never", "b" to "a", "a" to null)) + + val report = MarmotReindexSweep.run( + stored = 3, + unresolved = listOf("stuck", "b", "a"), + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(2, report.recovered) + assertEquals(1, report.failed, "the unreadable one is reported, not retried forever") + assertEquals(report.unresolved, report.recovered + report.failed, "every event is accounted for") + } + + /** + * The pass cap only bites on the shape where each pass recovers exactly one + * event, which is the only way the sweep can go quadratic. Six events in a + * chain replayed worst-first need six passes and get five. + */ + @Test + fun `the pass cap bounds a chain longer than it`() = runTest { + val chain = listOf("f", "e", "d", "c", "b", "a") + val backlog = Backlog( + mapOf("f" to "e", "e" to "d", "d" to "c", "c" to "b", "b" to "a", "a" to null) + ) + + val report = MarmotReindexSweep.run( + stored = chain.size, + unresolved = chain, + replay = backlog::replay, + isUnresolved = backlog::isUnresolved, + ) + + assertEquals(MarmotReindexSweep.DEFAULT_MAX_PASSES, report.recovered) + assertEquals(1, report.failed, "what the cap cut short is reported as still unread") + } + + @Test + fun `a replay that throws is survived by the rest of the sweep`() = runTest { + val resolved = mutableSetOf() + + val report = MarmotReindexSweep.run( + stored = 3, + unresolved = listOf("throws", "a", "b"), + replay = { id -> + if (id == "throws") error("no key for this epoch") + resolved.add(id) + }, + isUnresolved = { id -> id !in resolved }, + ) + + assertEquals(2, report.recovered, "a throw must not abandon the events behind it") + assertEquals(1, report.failed) + } + + @Test + fun `nothing unresolved does no work at all`() = runTest { + var replays = 0 + + val report = MarmotReindexSweep.run( + stored = 40, + unresolved = emptyList(), + replay = { replays++ }, + isUnresolved = { true }, + ) + + assertEquals(0, replays, "a room where nothing went wrong must be left alone") + assertTrue(report.isNoOp) + assertEquals(40, report.stored) + } + + /** + * The probe, not the replay call, is what counts a recovery: a group event can + * be replayed without error and still leave the room with nothing to show for + * it -- a commit rejected as a duplicate does exactly that. + */ + @Test + fun `a replay that raises nothing but resolves nothing is not counted as recovered`() = runTest { + val report = MarmotReindexSweep.run( + stored = 1, + unresolved = listOf("duplicateCommit"), + replay = { /* applies cleanly, writes no line */ }, + isUnresolved = { true }, + ) + + assertEquals(0, report.recovered) + assertEquals(1, report.failed) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt new file mode 100644 index 00000000..f585390b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -0,0 +1,227 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.nip30303.ArtifactEvent + +/** + * An artifact the group signed, from proposal to rows, against real FROST. + * + * Adding an artifact used to write a row and submit it; the row said the + * submitter wrote it, because they had. Now the group signs it, and the claim + * this test exists to hold is that the artifact every device ends up with is + * the group's: authored by the threshold key, carrying a signature that + * verifies, with an id every member arrives at independently. + * + * None of that is visible when it breaks. A row whose author is the proposer + * looks exactly like a row whose author is the group -- both are opaque hex -- + * and a group that disagrees about the id has two artifacts that look like one. + */ +class SignedArtifactTest { + private val participants = 3 + private val threshold = 2 + private val chatRoomId = "room" + + /** The member who filled in the form. Nothing they own should end up on the row. */ + private val proposer = "9".repeat(64) + private val dialectId = "b".repeat(64) + + /** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + /** + * Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay + * exactly that: the proposer's fields re-authored under the group's key. + */ + private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + + private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = chatRoomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + dkgSessionId = "k".repeat(64), + threshold = threshold, + participantCount = participants, + signerId = 0, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = "f".repeat(64) + ) + + /** A quorum signing the session's event, in the manager's order. */ + private fun groupSignature(session: FrostSigningSession): String { + val message = ByteVector(session.eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } + + /** Everything from the form to the row a device holds afterwards. */ + private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { + val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + return ArtifactEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } + + @Test + fun `the artifact the group signs is authored by the group, not the proposer`() { + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertEquals(groupPubKey, artifact.publicKey) + assertNotEquals(proposer, artifact.publicKey) + } + + @Test + fun `the row's id is the id the group put its signature to`() { + // Every device builds this row from the same signed event, so the id has + // to be the one that was signed rather than anything recomputed from the + // proposer. Otherwise members converge on nothing and each holds its own + // copy of what is meant to be one artifact. + val session = sessionOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + + val artifact = MantraArtifact.fromArtifactEvent( + ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), + chatRoomId + ) + + assertEquals(session.eventId, artifact?.id) + } + + @Test + fun `the signature on the row verifies against the row's own id and author`() { + // The payoff of signing rather than submitting: the row carries proof the + // group made it, checkable by anybody holding it. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertNotNull(artifact) + assertTrue( + Nip01Crypto.verify( + signature = artifact.signature.hexToByteArray(), + hash = artifact.id.hexToByteArray(), + pubKey = artifact.publicKey.hexToByteArray() + ), + "an artifact row should carry a signature the group's key made over its own id" + ) + } + + @Test + fun `what the form asked for is what the group signed`() { + // The fields travel as tags through a session that knows nothing about + // artifacts. Anything dropped in there is signed away silently. + val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) + + assertEquals("In Detention", artifact?.name) + assertEquals("https://example.com/in-detention", artifact?.url) + assertEquals("private", artifact?.visibility) + assertEquals("cc", artifact?.license) + assertEquals(dialectId, artifact?.dialectId) + } + + @Test + fun `the artifact arrives with the first version hanging off it`() { + // Nothing sends this row: each device derives it from the artifact it + // just applied. A chapter attaches to a version rather than to an + // artifact, so an artifact that arrives without one is inert. + val signed = signedArtifactEvent(versionLabel = "First Edition") + + val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId) + val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId) + + assertNotNull(version) + assertEquals(artifact?.id, version.artifactId) + assertEquals("First Edition", version.versionLabel) + assertEquals(groupPubKey, version.publicKey) + // Derived, not signed: the group signed the artifact that declares it. + assertEquals("", version.signature) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt new file mode 100644 index 00000000..25a13e0d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayBackPressureTest.kt @@ -0,0 +1,58 @@ +package press.mantra.compose.network.relays + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins which CLOSED reasons mean "ease off". + * + * The classification decides what happens next and is expensive to get wrong in both + * directions: a refusal read as a transient failure is answered by opening another + * subscription, which is how one "too many concurrent REQs" becomes a flood of them, while an + * unsupported filter read as back-pressure leaves a subscription shut for five minutes over a + * problem no amount of waiting fixes. + */ +class RelayBackPressureTest { + + @Test + fun `recognises the NIP-01 machine-readable prefix`() { + assertTrue(isRelayBackPressure("rate-limited: slow down there chief")) + } + + @Test + fun `recognises the free-text forms relays actually send`() { + listOf( + "ERROR: too many concurrent REQs", + "rate limit exceeded", + "Please slow down", + "TOO MANY SUBSCRIPTIONS", + "maximum concurrent subscriptions reached", + ).forEach { reason -> + assertTrue(isRelayBackPressure(reason), "expected back-pressure: $reason") + } + } + + @Test + fun `does not read a refusal we cannot wait out as back-pressure`() { + listOf( + "auth-required: we can't serve DMs to unauthenticated users", + "unsupported: filter contains unknown tag", + "invalid: filter is empty", + "error: negentropy disabled", + "blocked: you are not allowed to write here", + ).forEach { reason -> + assertFalse(isRelayBackPressure(reason), "did not expect back-pressure: $reason") + } + } + + /** + * A CLOSED with no reason at all is common. Treating it as back-pressure would mean the + * least informative refusal produced the longest possible outage. + */ + @Test + fun `a missing or empty reason is not back-pressure`() { + assertFalse(isRelayBackPressure(null)) + assertFalse(isRelayBackPressure("")) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt new file mode 100644 index 00000000..decc69cd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt @@ -0,0 +1,392 @@ +package press.mantra.compose.network.relays + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.NostrSocketClient +import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.SocketConnectionClosedCallback +import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback +import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback +import press.mantra.compose.repository.CachingImportRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The pool's half of surviving a dropped socket. + * + * A subscription that outlives its socket only works if the pool remembers what that socket was + * carrying and hands it back on reconnect. None of that is observable from the outside — no + * return value changes, nothing throws — so a regression here looks like "messages stopped + * arriving after a tunnel", hours later, on someone else's phone. + * + * The other half is that a relay answers a repeated REQ on an existing subscription id by + * replacing that subscription's filter, which is what makes replay a send rather than a + * close-and-reopen. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RelayPoolSubscriptionTest { + + private val relayUrl = "wss://relay.example.com" + private val otherRelayUrl = "wss://other.example.com" + + private fun req(subId: String, kind: Int = 1) = + ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind)))) + + @Test + fun `a query is retained, and replayed when its socket comes back`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + + val socket = factory.only() + assertEquals(1, socket.sent.size, "the REQ should have gone out once") + assertTrue(socket.autoReconnect, "a relay carrying a subscription is worth reconnecting") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertEquals(1, socket.sent.size, "the REQ should have been replayed") + assertTrue(socket.sent.single().contains("sub-1")) + } + + @Test + fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl) + + val socket = factory.only() + assertFalse(socket.autoReconnect, "nothing is open on this relay any more") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty(), "a closed subscription must not come back on reconnect") + } + + /** + * Closing one of two must not drop the other — the bug this guards is a shared socket + * quietly losing its remaining subscription because a sibling finished first. + */ + @Test + fun `closing one subscription leaves the rest of that relay's alone`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.query(req("sub-2", kind = 7), relayUrl) + pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl) + + val socket = factory.only() + assertTrue(socket.autoReconnect, "sub-2 is still open") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertEquals(1, socket.sent.size) + assertTrue(socket.sent.single().contains("sub-2")) + } + + /** + * Negentropy is stateful: NEG-OPEN carries a fingerprint of the local set and every round + * depends on the last. Replaying one mid-exchange would reconcile against a conversation + * the relay is no longer having, so an interrupted exchange is abandoned and re-queued + * instead. + */ + @Test + fun `a negentropy exchange is never replayed`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.negentropySync( + NegOpenCmd(subId = "neg-1", filter = Filter(kinds = listOf(1)), initialMessage = "6100"), + relayUrl, + ) + + val socket = factory.only() + assertEquals(1, socket.sent.size, "NEG-OPEN still goes out") + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty(), "a half-finished reconciliation must not be resumed") + } + + @Test + fun `a live subscription is retained like any other`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + + val socket = factory.only() + assertTrue(socket.autoReconnect) + + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.single().contains("live-giftwrap")) + } + + /** + * Widening a live subscription has to replace what gets replayed, not just what is on the + * wire now. Retaining the old filter would mean a reconnect quietly restored a + * subscription the caller had already moved on from — a group you just joined going + * silent the first time you walked through a tunnel. + */ + @Test + fun `updating a live subscription replaces what a reconnect replays`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl) + pool.updateLiveSubscription( + ReqCmd( + subId = "live-groups-0", + filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))), + ), + relayUrl, + ) + + val socket = factory.only() + socket.sent.clear() + factory.reopen(relayUrl) + + val replayed = socket.sent.single() + assertTrue(replayed.contains("group-b"), "expected the current filter, got: $replayed") + } + + /** + * An update that cannot be sent must still be recorded. Throwing instead would leave the + * previous filter retained, which is the one outcome worse than not sending at all. + */ + @Test + fun `an update whose send fails is still retained for the reconnect`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl) + + val socket = factory.only() + socket.failSends = true + pool.updateLiveSubscription( + ReqCmd( + subId = "live-groups-0", + filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))), + ), + relayUrl, + ) + + socket.failSends = false + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.single().contains("group-b")) + } + + @Test + fun `dropping a relay forgets what it was carrying`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.changeRelays(listOf(RelayDTO(url = otherRelayUrl, read = true, write = true))) + + val dropped = factory.forUrl(relayUrl) + dropped.sent.clear() + factory.reopen(relayUrl) + + assertTrue(dropped.sent.isEmpty(), "a relay we removed should not be re-subscribed") + } + + @Test + fun `closing the pool forgets everything`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-1"), relayUrl) + pool.closePool() + + val socket = factory.only() + socket.sent.clear() + factory.reopen(relayUrl) + + assertTrue(socket.sent.isEmpty()) + } + + /** Retention is per relay: one relay's reconnect must not re-send another's REQ. */ + @Test + fun `replay is scoped to the relay that reconnected`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + pool.query(req("sub-here"), relayUrl) + pool.query(req("sub-there"), otherRelayUrl) + + factory.forUrl(relayUrl).sent.clear() + factory.forUrl(otherRelayUrl).sent.clear() + + factory.reopen(relayUrl) + + assertTrue(factory.forUrl(relayUrl).sent.single().contains("sub-here")) + assertTrue(factory.forUrl(otherRelayUrl).sent.isEmpty()) + } + + /** + * The semantic the whole change rests on. For a one-shot request EOSE is the end; for a + * live one it is only the boundary between the history a relay had stored and the tail it + * will now stream. Ending there is precisely what made every chat sync a poll. + */ + @Test + fun `a live subscription keeps delivering after EOSE`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + val socket = factory.only() + socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "live-giftwrap")) + socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "live-giftwrap")) + + assertEquals(2, received.size, "the event after EOSE should still have arrived") + assertTrue(collecting.isActive, "a live subscription ends when its collector stops, not at EOSE") + + collecting.cancel() + } + + /** The contrast, so the two rules cannot silently converge. */ + @Test + fun `a one-shot query still ends at EOSE`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.query(req("sub-1"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + val socket = factory.only() + socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "sub-1")) + socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "sub-1")) + + assertEquals(1, received.size, "nothing should arrive after the EOSE that ended it") + assertFalse(collecting.isActive, "the collector should have completed") + } + + /** + * A NOTICE carries no subscription id, so the socket hands it to every collector. It is + * admitted deliberately — it is the only signal some relays give for "negentropy disabled" + * — but it must stay advisory, never terminal, or one relay's complaint would tear down + * every unrelated subscription on that socket. + */ + @Test + fun `a NOTICE reaches a live subscription without ending it`() = runTest { + val factory = FakeSocketClientFactory() + val pool = pool(factory) + + val received = mutableListOf() + val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl) + val collecting = launch(UnconfinedTestDispatcher(testScheduler)) { + flow.collect { received += it } + } + + factory.only().deliver(NostrIncomingMessage.NoticeMessage(message = "restricted: slow")) + + assertEquals(1, received.size) + assertTrue(collecting.isActive) + + collecting.cancel() + } + + private fun kotlinx.coroutines.test.TestScope.pool(factory: FakeSocketClientFactory) = + RelayPool( + nostrSocketClientFactory = factory, + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + // Unconfined so the pool's launched work — status updates, and the replay a + // reconnect triggers — has run by the time the call that started it returns. + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + ) +} + +private class FakeSocketClientFactory : NostrSocketClientFactory { + private val clients = mutableMapOf() + private val reopenCallbacks = mutableMapOf() + + override fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, + ): NostrSocketClient { + reopenCallbacks[wssUrl] = onSocketConnectionReopened + + return FakeNostrSocketClient(wssUrl).also { clients[wssUrl] = it } + } + + fun only(): FakeNostrSocketClient = clients.values.single() + + fun forUrl(url: String): FakeNostrSocketClient = clients.getValue(url) + + /** Stands in for a socket that dropped and re-established its session. */ + fun reopen(url: String) { + reopenCallbacks.getValue(url)?.invoke(url) + } +} + +private class FakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient { + val sent = mutableListOf() + var failSends = false + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + override val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + + override var autoReconnect: Boolean = false + + override suspend fun close() = Unit + + override suspend fun ensureSocketConnectionOrThrow() = Unit + + override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) { + if (failSends) throw IllegalStateException("socket is down") + sent += text + } + + override suspend fun sendAUTH(signedEvent: JsonObject) = Unit + + override suspend fun sendCLOSE(subscriptionId: String) = Unit + + override suspend fun sendCOUNT(data: JsonObject): String = "unused" + + override suspend fun sendEVENT(signedEvent: JsonObject) = Unit + + override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit + + /** Stands in for a message arriving on the wire. */ + suspend fun deliver(message: NostrIncomingMessage) { + _incomingMessages.emit(message) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt new file mode 100644 index 00000000..0c4cb389 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt @@ -0,0 +1,73 @@ +package press.mantra.compose.network.sockets + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class ReconnectBackoffTest { + + private val initial = 1.seconds + private val max = 60.seconds + + private fun delay(attempt: Int, jitter: Double = 0.0) = + reconnectDelay( + attempt = attempt, + initialDelay = initial, + maxDelay = max, + jitterFraction = 0.25, + jitter = jitter, + ) + + @Test + fun `doubles once per previous failure`() { + assertEquals(1.seconds, delay(attempt = 1)) + assertEquals(2.seconds, delay(attempt = 2)) + assertEquals(4.seconds, delay(attempt = 3)) + assertEquals(8.seconds, delay(attempt = 4)) + } + + @Test + fun `stops growing at the cap`() { + assertEquals(max, delay(attempt = 7)) + assertEquals(max, delay(attempt = 50)) + } + + /** + * The reason [MAX_RECONNECT_EXPONENT] exists. `2.0.pow(4000)` is `Infinity`, and + * `Duration * Double` throws on it — so without the clamp a socket that had been failing + * for long enough turned its own reconnect loop into a crash loop, at roughly the moment + * the network was least likely to recover unaided. + */ + @Test + fun `survives an attempt count large enough to overflow the doubling`() { + assertEquals(max, delay(attempt = 4_000)) + assertEquals(max, delay(attempt = Int.MAX_VALUE)) + } + + /** Defensive: a caller that starts counting at zero should still get a usable delay. */ + @Test + fun `treats a non-positive attempt as the first one`() { + assertEquals(initial, delay(attempt = 0)) + assertEquals(initial, delay(attempt = -3)) + } + + @Test + fun `adds at most the jitter fraction on top, never subtracts`() { + assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0)) + assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0)) + + val midway = delay(attempt = 3, jitter = 0.5) + assertTrue(midway > 4.seconds && midway < 5.seconds, "expected 4s..5s, got $midway") + } + + /** + * Jitter is applied after the cap, so a capped delay still spreads: relays all drop at the + * same moment when the network does, and identical waits would bring them back in lockstep + * for as long as the outage lasted. + */ + @Test + fun `jitter still spreads a capped delay`() { + assertTrue(delay(attempt = 50, jitter = 1.0) > max) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt new file mode 100644 index 00000000..5df982dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.ChatMessage +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Whether an encrypted group event actually leaves the device. + * + * The bug this pins did not throw, log, or fail a build. `MarmotOutboundDao` + * wrote the broadcast rows inside `chatMessageOrNull?.let { }`, so an event with + * no chat line pointing at it was MLS-encrypted, wrapped, persisted, its queue + * row marked processed -- and never sent. FROST signing proposals and the room's + * key announcement are exactly that: traffic the room generates, which + * deliberately writes no ChatMessage of its own. Every proposal was silently + * delivered to nobody. + * + * The DAO around this is Room-backed and cannot be stood up here, which is how + * the gate survived unnoticed in the first place. So the decision is tested + * where it can be seen, and the DAO does nothing with it but write what it says. + */ +class MarmotDeliveryTest { + private val groupEventId = "a".repeat(64) + private val relays = Relays.DefaultDMRelayList + + private val chatLine = ChatMessage( + id = 42L, + senderPublicKey = "b".repeat(64), + isUserMessage = true, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = "c".repeat(64), + chatRoomId = "room", + content = "the vote is at six", + ) + + @Test + fun `a signing proposal goes out, though nothing in the chat points at it`() { + // The regression. A FROST message has no ChatMessage by design -- the manager + // writes its own transcript lines from what arrives, so a row here would be a + // second, worse account of the same thing -- and that must not be the reason the + // group never hears about it. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + + assertTrue(delivery.isProtocolTraffic) + assertEquals(relays.size, delivery.broadcasts.size) + assertTrue(delivery.broadcasts.isNotEmpty(), "an event on disk and going nowhere") + } + + @Test + fun `the send does not depend on the transcript`() { + // Said as directly as it can be said: the two questions are independent. Anything + // that makes a broadcast conditional on a chat line fails here. + val protocol = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + val spoken = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertContentEquals( + protocol.broadcasts.map { it.relayURL }, + spoken.broadcasts.map { it.relayURL }, + ) + assertEquals(protocol.broadcasts.size, spoken.broadcasts.size) + } + + @Test + fun `every relay gets a request, naming the event`() { + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertContentEquals( + relays.map { it.url }, + delivery.broadcasts.map { it.relayURL }, + ) + assertTrue(delivery.broadcasts.all { it.nostrEventId == groupEventId }) + } + + @Test + fun `requests are queued pending, which is all the broadcaster looks at`() { + // `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that + // picks these up. A request written in any other state is as unsent as no request. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) + + assertTrue(delivery.broadcasts.all { it.status == "pending" }) + } + + @Test + fun `a member's message is linked to its chat line`() { + // The bookkeeping that legitimately does depend on there being a chat line: the + // transcript needs to know which event carried the words, so a sent message can + // be shown as sent. + val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) + + assertFalse(delivery.isProtocolTraffic) + assertEquals(chatLine.id, delivery.transcriptChatMessageId) + } + + @Test + fun `no relays is the only way an event stays home`() { + // Worth pinning as the single legitimate empty case, so an empty broadcast list + // is always read as "nowhere to send it" and never as "nothing to send". + val delivery = MarmotDelivery.plan(groupEventId, relays = emptyList(), chatMessage = chatLine) + + assertTrue(delivery.broadcasts.isEmpty()) + assertEquals(chatLine.id, delivery.transcriptChatMessageId) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt new file mode 100644 index 00000000..ee4b8473 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a device decides to do with an arriving wrap. + * + * This is where the check that replaces MIP-03 for kind:1059 is tested. The wrap itself + * names nobody, so everything a transcript says about who sent a private message rests on + * one comparison -- the seal's author against the identity MLS authenticated -- and if it + * stops being made, a member can have their words attributed to somebody else, or + * somebody else's attributed to them. The rest of the inbound path is Room-backed and + * cannot be unit-tested here, which is exactly why the decision was separated from the + * filing of it. + * + * `Ours` carries a second kind of risk. It is indistinguishable from `Unreadable` by + * decryption alone -- we cannot open our own wrap either -- and getting it wrong does not + * fail loudly: it files a placeholder over the only copy of the sender's own words. + */ +class MarmotDirectMessageDeliveryTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = NostrSignerSync(KeyPair()) + + private val alicePublicKey = alice.pubKey + private val bobPublicKey = bob.pubKey.toHexKey() + + private val at = 1_700_000_000L + private val text = "the vote is at six" + + private fun wrapFor( + signer: NostrSignerSync = alice, + recipient: String = bobPublicKey, + kind: Int = ChatMessageEvent.KIND, + content: String = text, + ): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = signer, + recipientPublicKey = recipient, + kind = kind, + createdAt = at, + tags = arrayOf(PTag.assemble(recipient, null)), + content = content, + ) + + @Test + fun `the recipient gets the words`() { + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, alicePublicKey) + + val readable = assertIs(delivery) + assertEquals(text, readable.opened.rumor.content) + assertEquals(alicePublicKey, readable.opened.seal.pubKey) + } + + @Test + fun `a bystander gets a line with nothing in it`() { + val carol = KeyPair() + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(wrapFor(), carol, alicePublicKey), + ) + } + + @Test + fun `our own message is ours, even though we cannot open it`() { + // The case that destroys data if it is missed. Alice cannot decrypt the wrap she + // sent -- its key was discarded -- so nothing about the payload distinguishes this + // from a bystander's view. Only the MLS sender identity does. Were it to come back + // Unreadable, the inbound path would file an empty line over the row sendChatMessage + // wrote, which is the only copy of those words anywhere. + val delivery = MarmotDirectMessage.classify(wrapFor(), alice.keyPair, alicePublicKey) + + assertEquals(MarmotDirectMessage.Delivery.Ours, delivery) + } + + @Test + fun `ours is decided before anything is opened`() { + // Same as above, from the other direction: a wrap Alice could never open, that is + // not even addressed to her, is still hers if MLS says she sent it. Nothing here + // may depend on decryption succeeding. + val toEve = wrapFor(recipient = eve.pubKey) + + assertEquals( + MarmotDirectMessage.Delivery.Ours, + MarmotDirectMessage.classify(toEve, alice.keyPair, alicePublicKey), + ) + } + + @Test + fun `a message with no sender identity is dropped`() { + // Attribution has no other source. A line that cannot say who sent it must not be + // rendered at all rather than be attributed to the wrap, which names nobody. + val delivery = MarmotDirectMessage.classify(wrapFor(), bob, null) + + assertIs(delivery) + } + + @Test + fun `words sealed by one member and sent by another are dropped`() { + // The check that replaces MIP-03. Eve seals to Bob and sends; if MLS says the + // sender was Alice, the two disagree about who is speaking and the message is not + // rendered as anybody's. Without this, the seal inside is free to claim an author + // the frame does not support. + val delivery = MarmotDirectMessage.classify(wrapFor(signer = eve), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains(eve.pubKey) && rejected.reason.contains(alicePublicKey), + "the reason should name both parties to the disagreement: ${rejected.reason}", + ) + } + + @Test + fun `a seal relabelled with another author cannot even be opened`() { + // Eve's seal with Alice's pubkey written over it. This never reaches the author + // check, because NIP-44 derives the conversation key from the pubkey being + // claimed: relabelling a seal makes it undecryptable by the person it was + // encrypted for. The label is bound to the key, not merely asserted alongside it. + // + // So the outcome is Unreadable rather than Rejected -- Bob genuinely cannot read + // it -- and the group sees a private message it cannot open, which is the truth. + val forged = resealedAs(alicePublicKey, sealedBy = eve, to = bob) + + assertEquals( + MarmotDirectMessage.Delivery.Unreadable, + MarmotDirectMessage.classify(forged, bob, alicePublicKey), + ) + } + + @Test + fun `a seal tampered with after signing is dropped`() { + // Alice's own seal with its timestamp altered. Everything the previous test + // relies on still holds -- the pubkey is Alice's, so the content decrypts -- and + // the pubkey matches what MLS says, so the author check passes. Only the + // signature is left, and it is what catches this: the id no longer commits to the + // fields, so verify() fails. + val tampered = tamperedTimestamp(sealedBy = alice, to = bob) + + val delivery = MarmotDirectMessage.classify(tampered, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("does not verify"), + "expected a signature rejection, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor by somebody other than the sealer is dropped`() { + // A correctly signed seal by Alice, wrapped around a rumor claiming to be Eve's. + // Everything outside the innermost layer checks out; the words would be filed + // under whoever the rumor names if this were not caught. + val mismatched = sealAroundForeignRumor(sealedBy = alice, rumorFrom = eve.pubKey, to = bobPublicKey) + + val delivery = MarmotDirectMessage.classify(mismatched, bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("inside a seal by"), + "expected a rumor/seal author mismatch, got: ${rejected.reason}", + ) + } + + @Test + fun `a rumor of the wrong kind is dropped`() { + // kind:9 is what an ordinary group message uses. Arriving gift wrapped it is not a + // direct message this version knows how to render, and rendering it as one would + // put a group message behind a lock. + val delivery = MarmotDirectMessage.classify(wrapFor(kind = ChatEvent.KIND), bob, alicePublicKey) + + val rejected = assertIs(delivery) + assertTrue( + rejected.reason.contains("${ChatEvent.KIND}"), + "expected the offending kind in the reason, got: ${rejected.reason}", + ) + } + + /** [sealedBy]'s seal with [claimedAuthor] written over its pubkey, wrapped for [to]. */ + private fun resealedAs( + claimedAuthor: String, + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val relabelled = + Event( + id = seal.id, + pubKey = claimedAuthor, + createdAt = seal.createdAt, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = relabelled, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** [sealedBy]'s own seal, its timestamp altered after signing, wrapped for [to]. */ + private fun tamperedTimestamp( + sealedBy: NostrSignerSync, + to: KeyPair, + ): GiftWrapEvent { + val honest = MarmotDirectMessage.wrap( + signer = sealedBy, + recipientPublicKey = to.pubKey.toHexKey(), + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = emptyArray(), + content = text, + ) + val seal = MarmotDirectMessage.open(honest, to)!!.seal + + val altered = + Event( + id = seal.id, + pubKey = seal.pubKey, + createdAt = seal.createdAt + 86_400, + kind = SealedRumorEvent.KIND, + tags = seal.tags, + content = seal.content, + sig = seal.sig, + ) + + return GiftWrapEvent.create( + event = altered, + recipientPubKey = to.pubKey.toHexKey(), + createdAt = at, + ) + } + + /** A properly signed seal by [sealedBy] whose rumor claims [rumorFrom] wrote it. */ + private fun sealAroundForeignRumor( + sealedBy: NostrSignerSync, + rumorFrom: String, + to: String, + ): GiftWrapEvent { + val rumor = MarmotDirectMessage.rumor(rumorFrom, ChatMessageEvent.KIND, at, emptyArray(), text) + + val seal = + sealedBy.signNormal( + createdAt = at, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = sealedBy.nip44Encrypt(rumor.toJson(), to), + ) + + return GiftWrapEvent.create(event = seal, recipientPubKey = to, createdAt = at) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt new file mode 100644 index 00000000..1d3d2474 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -0,0 +1,185 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The envelope a direct message travels in, run against real secp256k1 and real NIP-44. + * + * Two of these assert properties that read like bugs and are not. The wrap names nobody, + * so the group can only learn who sent it from the MLS frame around it; and the sender + * cannot reopen their own message, because the key that sealed it was discarded. Both are + * consequences of using a throwaway key, both are documented in + * docs/marmot-direct-messages.md, and both are here so that a later change which quietly + * reverses them fails rather than ships. + */ +class MarmotDirectMessageTest { + private val alice = NostrSignerSync(KeyPair()) + private val bob = KeyPair() + private val eve = KeyPair() + + private val bobPublicKey = bob.pubKey.toHexKey() + private val at = 1_700_000_000L + private val text = "the vote is at six, do not tell the room" + + private fun aliceWrapsForBob(content: String = text): GiftWrapEvent = + MarmotDirectMessage.wrap( + signer = alice, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = content, + ) + + @Test + fun `the recipient reads what the sender wrote`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob), "Bob could not open a wrap addressed to him") + + assertEquals(text, opened.rumor.content) + assertEquals(alice.pubKey, opened.rumor.pubKey) + assertEquals(ChatMessageEvent.KIND, opened.rumor.kind) + } + + @Test + fun `the sender cannot reopen their own message`() { + // The throwaway key is gone, so this is unrecoverable by construction. Asserted + // rather than merely documented, because the obvious "fix" -- persisting the + // throwaway private key -- would be strictly worse than the identity-keyed wrap + // this design was chosen over, and would reintroduce the attribution the + // throwaway key exists to remove. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), alice.keyPair)) + } + + @Test + fun `a bystander gets nothing, and no exception`() { + // Null rather than a throw: the caller is midway through processing a kind:445 + // that carries a real message for somebody, and an exception would abandon it. + assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) + } + + @Test + fun `the wrap names nobody`() { + val first = aliceWrapsForBob() + val second = aliceWrapsForBob() + + assertNotEquals(alice.pubKey, first.pubKey, "the wrap is keyed to its sender") + assertNotEquals(bobPublicKey, first.pubKey, "the wrap is keyed to its recipient") + assertNotEquals(first.pubKey, second.pubKey, "the throwaway key is being reused") + + // Signed by the throwaway key, per NIP-59. The signature attributes nothing -- + // the signer is meaningless and discarded -- and keeping it is what makes this a + // real gift wrap that GiftWrapEvent.create builds and unwrapOrNull opens. + assertTrue(first.verify(), "the wrap does not verify against its own key") + assertEquals(GiftWrapEvent.KIND, first.kind) + } + + @Test + fun `the seal is what binds the words to their author`() { + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(SealedRumorEvent.KIND, opened.seal.kind) + // The inbound path checks exactly this pair against the MLS sender identity. It + // is what replaces the MIP-03 pubkey check for kind:1059, so if the seal stops + // being signed by the sender, the carve-out becomes a hole. + assertEquals(alice.pubKey, opened.seal.pubKey) + assertTrue(opened.seal.verify(), "the seal's signature does not verify") + assertEquals(opened.seal.pubKey, opened.rumor.pubKey) + } + + @Test + fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and + // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's + // is the inbound check that the seal's pubkey is the MLS sender's identity. This + // test pins the half open() is responsible for: the pubkey it reports is Eve's. + val eveSigner = NostrSignerSync(eve) + val forged = + MarmotDirectMessage.wrap( + signer = eveSigner, + recipientPublicKey = bobPublicKey, + kind = ChatMessageEvent.KIND, + createdAt = at, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "alice here, send the funds", + ) + + val opened = assertNotNull(MarmotDirectMessage.open(forged, bob)) + + assertEquals(eveSigner.pubKey, opened.seal.pubKey) + assertNotEquals(alice.pubKey, opened.seal.pubKey) + } + + @Test + fun `the rumor id is the one the sender queued`() { + val tags = arrayOf(PTag.assemble(bobPublicKey, null)) + + // What the outbound queue computes before it has a signer, and what it later uses + // to find the chat message to link and broadcast. If these ever diverge the + // message is encrypted, stored, and silently never sent. + val queued = + EventHasher.hashId( + alice.pubKey, + at, + ChatMessageEvent.KIND, + tags, + text, + ) + + val opened = assertNotNull(MarmotDirectMessage.open(aliceWrapsForBob(), bob)) + + assertEquals(queued, opened.rumor.id) + assertEquals(queued, MarmotDirectMessage.rumor(alice.pubKey, ChatMessageEvent.KIND, at, tags, text).id) + } + + @Test + fun `no layer is fuzzed into the past`() { + val wrap = aliceWrapsForBob() + val opened = assertNotNull(MarmotDirectMessage.open(wrap, bob)) + + // NIP-59 defaults every one of these to randomWithTwoDays(). Inside MLS that only + // scatters the bystander line up to two days out of position in every other + // member's transcript, so all three carry the real message time. + assertEquals(at, wrap.createdAt) + assertEquals(at, opened.seal.createdAt) + assertEquals(at, opened.rumor.createdAt) + } + + @Test + fun `the recipient is on the wrap where the group can see it`() { + val wrap = aliceWrapsForBob() + + // Deliberate: it is what lets a bystander's transcript say who the message was + // for. Removing it would hide the recipient from the group at the cost of the + // named line -- see the decisions table in docs/marmot-direct-messages.md. + assertEquals(bobPublicKey, wrap.tags.firstTagValue("p")) + } + + @Test + fun `garbage in the wrap does not become a message`() { + val notAWrap = + alice.signNormal( + createdAt = at, + kind = GiftWrapEvent.KIND, + tags = arrayOf(PTag.assemble(bobPublicKey, null)), + content = "not nip-44 at all", + ) + + assertNull(MarmotDirectMessage.open(notAWrap, bob)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt new file mode 100644 index 00000000..8ecf0c8f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/Nip17FiltersTest.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.query.NostrEventFilterQuery +import press.mantra.compose.network.serialization.encodeToJsonString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Pins the only gift wrap filter that can come back with something we can read. + * + * Every clause here is one that was got wrong in production. Two call sites asked + * for `authors = [our pubkey]`, which cannot match a wrap signed by a throwaway + * key and so returned nothing at all, silently, for as long as it existed. A third + * asked for `p = [the peer]`, which returned other people's mail and crashed the + * save that tried to unseal it. Neither failure was visible from reading the + * filter, so the shape is asserted instead of trusted. + */ +class Nip17FiltersTest { + + private val us = "a".repeat(64) + + @Test + fun `it asks for wraps addressed to us`() { + assertEquals(mapOf("p" to listOf(us)), Nip17Filters.inbox(us).tags) + } + + @Test + fun `it constrains no authors`() { + // GiftWrapEvent.create signs with a key it generates and drops, so the author + // of a wrap is a value nobody can predict -- least of all the sender's own + // pubkey. Any authors clause here silently matches zero events on every relay. + assertNull(Nip17Filters.inbox(us).authors) + } + + @Test + fun `it carries no since cursor`() { + // A wrap is stamped up to two days earlier than it was sent, so a high-water + // mark taken from the newest wrap we hold skips mail that arrives behind it. + // Anything reintroducing `since` has to back-date by at least two days first. + assertNull(Nip17Filters.inbox(us).since) + assertNull(Nip17Filters.inbox(us).until) + } + + @Test + fun `it asks for gift wraps and nothing else`() { + assertEquals(listOf(1059), Nip17Filters.inbox(us).kinds?.toList()) + } + + @Test + fun `two callers asking for the same inbox make one request`() { + // computeId hashes the encoded filter, so the chat room list and the chat + // message screen collapse into a single negentropy request only while both + // encode identically. Building the filter once is what holds that true; the + // wire shape is asserted so an added default cannot quietly split them. + assertEquals(Nip17Filters.inbox(us), Nip17Filters.inbox(us)) + assertEquals( + """{"kinds":[1059],"tags":{"p":["$us"]},"limit":50}""", + Nip17Filters.inbox(us).encodeToJsonString(), + ) + } + + @Test + fun `the local set it builds is the same set the relay is asked for`() { + // Negentropy reconciles our local set against the relay's: this filter goes out + // in NEG-OPEN, and the local side is built by running the same filter through + // NostrEventFilterQuery. A clause that survives one trip and not the other + // reports differences that are not real -- events re-downloaded forever, or + // pushed at a relay that excluded them on purpose. What matters here is that + // the local query reads the p tag and, like the wire filter, bounds no author: + // an authors clause would show up as `pubKey IN (?)`. + val query = NostrEventFilterQuery.build(Nip17Filters.inbox(us)) + + assertEquals( + "SELECT * FROM NostrEvent WHERE kind IN (?) AND (tags LIKE ? ESCAPE '\\') " + + "ORDER BY createdAt DESC, id DESC", + query.sql, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt new file mode 100644 index 00000000..db212a4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt @@ -0,0 +1,118 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag + +/** + * What the form collects has to reach the members deciding whether to sign it. + * + * An artifact proposal leaves the proposer's device as a kind, a tag array and + * a string, and everything a member is shown before signing -- and every row + * built afterwards -- is read back out of those. A field that does not survive + * the trip is not a visible failure: the artifact still appears, just without + * a url, or a source dialect, or a first version, on every device but the one + * that typed it. + */ +class ArtifactEventTest { + private val groupKey = "a".repeat(64) + private val dialectId = "b".repeat(64) + + /** The event a member actually receives: bytes, with no template behind it. */ + private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate) = + ArtifactEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "c".repeat(128), + ) + + private fun proposal(versionLabel: String = "1.0") = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = versionLabel, + createdAt = 1_700_000_000L, + ) + + @Test + fun `every field the form collects survives the trip through the tags`() { + val artifact = readBack(proposal()) + + assertEquals("In Detention", artifact.content) + assertEquals("https://example.com/in-detention", artifact.url()) + assertEquals("private", artifact.visibility()) + assertEquals("cc", artifact.license()) + assertEquals(dialectId, artifact.dialectId()) + assertEquals("1.0", artifact.versionLabel()) + } + + @Test + fun `the proposal is the artifact's kind, so the signing screen can describe it`() { + // The session carries a kind and nothing else to go on. Get this wrong + // and members are asked to sign "event of kind 30300" -- a question + // nobody can answer. + assertEquals(ArtifactEvent.KIND, proposal().kind) + } + + @Test + fun `an artifact declares one version, whatever an initializer adds`() { + // addUnique, not add: two labels would leave receivers deriving two + // different first versions depending on which one they read first. + val template = ArtifactEvent.build( + name = "In Detention", + url = "https://example.com/in-detention", + visibility = "private", + license = "cc", + dialectId = dialectId, + versionLabel = "1.0", + ) { + addUnique(ArtifactVersionMetadataTag.assemble("2.0")) + } + + assertEquals( + 1, + template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + ) + } + + @Test + fun `an artifact from before the label existed reads back as declaring none`() { + // Not an error: it is what every artifact submitted the old way looks + // like, and they have to keep parsing rather than failing to load. + val template = proposal() + val older = Event( + id = "d".repeat(64), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags + .filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = "", + ) + + val artifact = ArtifactEvent( + older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig + ) + + assertNull(artifact.versionLabel()) + // Everything else still reads, so the artifact itself is unharmed. + assertEquals(dialectId, artifact.dialectId()) + assertEquals("https://example.com/in-detention", artifact.url()) + } +} diff --git a/docs/README.md b/docs/README.md index 777f3c3e..af213bf5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,9 +9,14 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | +| [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | -| [jvm-target.md](./jvm-target.md) | what desktop support would cost, phased — why the native chain is already done, why an empty source set in our phoenix fork is the real blocker, and why DAO tests do not need any of it | +| [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | +| [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | +| [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | -Start with the ceremony if you are new to this area; the other two both assume it. +Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" -report — it is silent, and it looks like every other kind of delivery failure. +report — it is silent, and it looks like every other kind of delivery failure. The +sync note stands alone, and the dead-code inventory reads as a follow-up to it. The +jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/dead-code.md b/docs/dead-code.md new file mode 100644 index 00000000..828838c2 --- /dev/null +++ b/docs/dead-code.md @@ -0,0 +1,198 @@ +# Unreferenced code in the sync and relay stack + +An inventory of code nothing calls, found while building the long-running chat +sync (see [long-running-sync.md](./long-running-sync.md)). Some of it was +orphaned by that change; most of it was already dead and only became visible +because the subsystem was being read closely. + +None of it is removed yet. It is written down rather than deleted because +several items are one decision away from being wanted, and that decision is not +the sync change's to make. + +## How this was checked + +Every claim below is "the identifier appears exactly once in +`composeApp/src`, at its own declaration", verified with a script that tokenises +every `.kt` file outside `build/` and counts occurrences: + +```bash +grep -rn "\bidentifierName\b" --include=*.kt composeApp/src | grep -v '/build/' +``` + +Line numbers below were correct at `f5eb744` and drift with every edit to those +files; treat them as a hint and confirm with the grep. + +Two things that method cannot see, so each item was also read in context: + +- **Room DAOs** are called through generated code as `database.xDao().method()`, + so the method name does appear at the call site and a genuinely unused DAO + query still counts as one occurrence. The DAO section below is therefore the + least certain. +- **Compose** entry points and `@Composable` functions invoked from `setContent` + or navigation graphs may be reached without a textual reference. + +## Orphaned by the live-sync change + +**`ChatRoomListViewModel.scheduleSynchronization`** — +[ChatRoomListViewModel.kt:80](composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt:80) + +No longer called by `initiate()`. It was kept as the entry point for a +user-initiated refresh, which is the one thing the live tier does not answer — +but no refresh affordance exists in the UI, so today it is unreferenced. + +It also now duplicates `LiveSubscriptionManager.queueCatchUpSynchronization`, +which queues the same two negentropy requests on every foreground, including the +first one after launch. Drift between the two is harmless (a negentropy request +is keyed on a hash of its filter, and both are valid reconciliations), but there +is no reason for two copies. + +**Decide one of:** wire a pull-to-refresh to it, or delete it. Deleting it also +orphans `nostrRepository` in that view model, which ripples into its `factory` +and into `HomeScreen` — the reason it was left alone rather than removed in the +same commit. + +## Dead on arrival, in the relay stack + +Each of these predates the sync work. + +| what | where | note | +|---|---|---| +| `RelayPool.removeRelays` | [RelayPool.kt:123](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:123) | never called; `changeRelays` and `closePool` cover every path that removes a relay | +| `RelayPool.hasRelays` | [RelayPool.kt:171](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:171) | never called | +| `RelayPool.transformWhileEventsAreIncoming` | [RelayPool.kt:486](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:486) | private, never called. Superseded by `completeOnSubscriptionEnd`, which ends a flow on EOSE/CLOSED/NEG-ERR rather than on "the last message was not an event" | +| the commented-out publish gate | [RelayPool.kt:547-560](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:547) | the only thing keeping `import kotlinx.coroutines.flow.transform` ([:30](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt:30)) alive | +| `RelaysSocketManager.clearRelayPools` | [RelaysSocketManager.kt:100](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:100) | private, never called. Nothing tears the pool down on sign-out | +| `RelaysSocketManager.tryConnectingToAllRelays` | [RelaysSocketManager.kt:131](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:131) | never called; the only caller of `RelayPool.tryConnectingToRelay`, which is otherwise dead too | +| the commented `tryConnectingToUserRelay` | [RelaysSocketManager.kt:139](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:139) | | +| `NostrIncomingMessage?.verifyOrThrow` | [NostrIncomingMessage.kt:59](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:59) | never called. It also treats any NOTICE as a failure, which is wrong for the reason `filterByEventId` documents — a NOTICE has no subscription id and reaches every collector | +| `String?.decodeFromJsonStringOrNull` | [CommonJson.kt:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/serialization/CommonJson.kt:37) | never called | +| `NostrSocketClientImpl.compressMessage` | [NostrSocketClientImpl.kt:368](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:368) | already carries `@Suppress("unused")`. Outgoing compression is not a thing any relay here asks for | +| `DefaultNostrSocketClientFactory.create(httpClient = …)` | [NostrSocketClientFactory.kt](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt) | the overload taking an explicit `HttpClient` has no caller; everything goes through the interface method and `defaultSocketsHttpClient` | + +### `RelaysSocketManager.userRelays` is a field that is never written + +[RelaysSocketManager.kt:52](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:52) + +Worth separating from the table, because it does not read as dead — it reads as +a bug. + +```kotlin +private val userRelays = mutableSetOf() // nothing ever adds to this +``` + +The `userRelays` at [:83](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:83) +is a *different, shadowing local* inside `observeRelays`. The field itself is +only ever read, at [:107](composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt:107): + +```kotlin +return if (userRelays.isNotEmpty()) { ...publish to the user's relays... } + else { ...publish to FALLBACK_RELAYS... } +``` + +So the single-argument `publishEvent` **always** takes the fallback branch, and +the user's own relay list is never used for publishing. Either populate the +field from `updateRelayPools` or delete it and the branch; leaving a permanently +false condition in place is the one option that keeps the bug. + +### The unused halves of the socket protocol + +`NostrSocketClient` declares five send methods. Only `sendEVENT` and +`sendMESSAGE` are ever called — everything else in the pool serialises its own +command with `OptimizedJsonMapper` and goes through `sendMESSAGE`. + +| declaration | impl | +|---|---| +| `sendREQ` [:37](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:37) | [:349](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:333) | +| `sendCLOSE` [:31](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:31) | [:361](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:345) | +| `sendCOUNT` [:33](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:33) | [:354](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:338) | +| `sendAUTH` [:29](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClient.kt:29) | [:365](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientImpl.kt:349) | + +Removing them also orphans `buildNostrREQMessage`, `buildNostrCOUNTMessage`, +`buildNostrCLOSEMessage` and `buildNostrAUTHMessage` in +`NostrOutgoingMessageBuilder`. + +Two of the four are worth keeping for a named reason rather than out of habit: + +- **`sendAUTH` is the NIP-42 hole.** `NostrIncomingMessage.AuthMessage` + ([:34](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:34)) + is parsed and then dropped — nothing anywhere handles an AUTH challenge. A + relay that answers CLOSED with `auth-required` is currently retried with + backoff forever and will never succeed. `sendAUTH` is half of the fix, so + delete it only alongside a decision not to support authenticated relays. +- **`sendCOUNT` and `CountMessage`** ([:38](composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrIncomingMessage.kt:38)) + are a matched pair with no caller. `CountMessage` is parsed and admitted by + `filterBySubscriptionId`, but since nothing sends a COUNT it can never arrive. + Both go, or neither. + +`sendREQ` and `sendCLOSE` have no such argument — the pool does not use them and +would not start. + +## Dead all the way down: the publisher chain + +**`NostrPublisherRepository`** — +[NostrPublisherRepository.kt:15](composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt:15) + +The whole class is unreferenced: nothing constructs it, and neither of its two +methods (`signPublishImportNostrEvent`, `publishRelayList`) is called. Publishing +goes through the broadcast queue in `SynchronizationViewModel` instead. + +It matters beyond itself because it is the **only** consumer of +`CachingImportRepository.importEvents`. `RelayPool` and `RelaysSocketManager` +both take a `cachingImportRepository` constructor parameter, store it, and never +dereference it — and `SynchronizationViewModel` satisfies that parameter with +`NO_OP_CACHING_IMPORT_REPOSITORY`. So the entire caching-import seam is a +parameter passed from nowhere to nothing. + +Removing `NostrPublisherRepository` and the two unused constructor parameters +would let `CachingImportRepository` go with them. + +## Dead columns (need a migration) + +`isRecommendedRelay` on both +[SynchronizeNostrEventRequest.kt:40](composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt:40) +and +[NegentropySynchronizeRequest.kt:39](composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt:39) +is never written by any caller (every construction takes the `false` default) +and never read. + +A different risk class from everything else here: these are Room columns, so +removing them is a schema change with a migration, not a delete. Cheap to leave; +worth folding into the next migration that touches those tables. + +## Wider sweep, needing per-item judgement + +The same sweep across all of `composeApp/src` turns up ~99 single-occurrence +function declarations. Most are outside the sync stack and were not read in +context. Two clusters stand out and are recorded here so the next person does +not have to re-derive them: + +- **Unused DAO queries** — roughly 30, concentrated in + `BroadcastNostrEventReceiptDao`, `BroadcastNostrEventRequestDao`, + `UnsignedNostrEventDao`, `RepostedRelationDao`, `QuotedRelationDao`, + `InReplyToRelationDao`, `ReactionDao` and `ZapDao`. Mostly + `getAllX()`/`getXById()` shapes that look like they were written to round out + a DAO rather than because something needed them. Cheap to delete and cheap to + re-add, but see the Room caveat above: confirm each against the generated + implementation, not just by grep. +- **Bech32/credential helpers** in + [Credentials.kt](composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Credentials.kt) — + `assureValidNpub`, `assureValidPubKeyHex`, `hexToNoteHrp`, `urlToLnUrlHrp`, + `bechToBytesOrThrow`. A conversion toolkit where only some conversions are + used. Worth keeping as a set if the intent is a complete encoding surface; + worth deleting if not. + +## Suggested order, if this gets done + +1. `NostrPublisherRepository` + the two `cachingImportRepository` parameters + + `CachingImportRepository`. Largest reduction, zero behavioural risk, and it + removes a constructor parameter from two classes at the centre of the relay + stack. +2. The relay-stack table above, plus the two commented-out blocks and the + `transform` import they keep alive. +3. `userRelays` — but as a **fix**, not a deletion, unless publishing to the + user's own relay list is deliberately not wanted. +4. `sendREQ`/`sendCLOSE`, keeping `sendAUTH` and `sendCOUNT` pending the NIP-42 + and COUNT decisions. +5. `ChatRoomListViewModel.scheduleSynchronization`, once there is a refresh + affordance or a decision that there will not be one. +6. `isRecommendedRelay`, with the next migration that touches those tables. diff --git a/docs/long-running-sync.md b/docs/long-running-sync.md new file mode 100644 index 00000000..56f4e5da --- /dev/null +++ b/docs/long-running-sync.md @@ -0,0 +1,254 @@ +# The long-running chat sync + +Chat messages arrive because a subscription is open, not because a screen asked. +This note is what that means, what it replaced, and the handful of decisions +inside it that look arbitrary and are not. + +Two subscriptions carry conversation, and they are held open for as long as the +app is in the foreground: + +- **kind 1059**, NIP-59 gift wraps p-tagged to us — DMs, and the Marmot Welcome + events that make us a member of a new group. +- **kind 445**, Marmot group events h-tagged with every group id we belong to. + +The code is [`LiveSubscriptionManager`](composeApp/src/commonMain/kotlin/press/mantra/compose/managers/LiveSubscriptionManager.kt), +built and scoped by `SynchronizationViewModel`. + +## What this replaced + +Every chat sync used to be a *pull*. A screen queued a request row, a pump in +`SynchronizationViewModel` drained it, the relay answered, the subscription was +closed at EOSE. Nothing arrived between pulls, so a message sent one second after +EOSE waited for the next time someone opened a screen. + +| screen | queued | +|---|---| +| `ChatRoomListViewModel.scheduleSynchronization` | negentropy over `1059 #p=[me]`, then over `445 #h=[every group id]`, per DM relay | +| `ChatMessageListViewModel.scheduleSynchronization` | for an MLS room, negentropy over `445 #h=[this group]`; for a NIP-17 room, gift wraps p-tagged at the peer | + +Neither runs on open any more. The first stays as the entry point for an +explicit user-initiated refresh — the one thing the live tier does not answer, +because it is the user saying they believe something is missing. The second now +does discovery only: if we do not hold a participant's kind-10050 we cannot +address a message to them, and that is worth resolving when a chat is opened +rather than whenever a background pass reaches it. + +## Why the old pipeline could not simply stop closing + +Five properties, each of which had to be undone deliberately. None was wrong; +all of them assume a subscription is short. + +**1. EOSE ends the flow.** `completeOnSubscriptionEnd` is what makes a collector +finish at all — the socket's `incomingMessages` is hot, and a filtered view of it +never completes on its own. `RelayPool.openLiveSubscription` is `query()` without +it: for a live subscription EOSE is only the boundary between stored history and +the live tail, and CLOSED is the only genuinely terminal message. + +**2. Every subscription was force-killed at 120 seconds.** `SUBSCRIPTION_TIMEOUT` +exists so a relay that goes quiet cannot park a slot forever. It applies to the +queue, and nothing else. + +**3. There are four subscription slots, total, across all relays.** +`subscriptionSlots` is a `Semaphore(4)`. A permanently-open subscription is a +permanently-held permit, so the live tier deliberately does not draw from it. The +two budgets have to be read together against what a relay tolerates per +connection (commonly ~20), not separately. + +**4. The queue row was the subscription identity and the provenance record.** Both +original `saveNostrEvent` overloads take a request object, use its `relayURL` and +`level`, and flip its status to "processed". A live subscription has no row and +never finishes, so there is a third overload carrying relay and level itself. + +**5. Nothing reconnected.** `NostrSocketClientImpl` caught a failure, called +`close()`, and fired a callback that only flipped a boolean nobody read. That was +survivable *only* because every subscription was short: the next queued request +opened a fresh socket on its way out. See "Reconnecting" below. + +There was a sixth, quieter one. `_incomingMessages` was a rendezvous +`MutableSharedFlow` emitted into from a coroutine launched per message, so +messages reached collectors in scheduling order rather than wire order, and an +emit with every collector busy blocked on the slowest. It now has a 256-message +buffer and is emitted into inline, on the reader. That also retired a 75ms sleep +before every EOSE, which existed to *hope* the events preceding it had already +been delivered. + +## How it holds together + +### Reconnecting + +A socket that drops now comes back on its own, with exponential backoff (1s +doubling to 60s, plus up to 25% jitter — every relay drops at once when the +network does, and without jitter they return in lockstep). `autoReconnect` is off +by default and owned by the pool, which turns it on for exactly as long as it is +retaining a subscription for that relay: reconnecting a socket nobody is +subscribed on is battery spent on nothing. + +`RelayPool` retains the REQ text per `(relay, subscription id)` and replays it +when that relay's socket is re-established. A relay answers a repeated REQ on the +same id by *replacing* that subscription's filter, so replay is a send rather +than a close-and-reopen — and the collector, attached to the socket **client** +rather than to a session, simply starts receiving again. A live subscription +therefore needs no drop handling of its own. + +Negentropy is deliberately not retained. NEG-OPEN carries a fingerprint of the +local set and each round depends on the last, so replaying one mid-exchange would +reconcile against a conversation the relay is no longer having. + +### The filters, and their timestamps + +The two kinds need opposite treatment, and it is visible in our own outbound code. + +Gift wraps are stamped with `TimeUtils.randomWithTwoDays()` +([`DatabaseChatRepository.kt:344`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt:344)), +so a wrap published *now* can carry a `created_at` two days old. **A `since` +anywhere near the present would silently drop a large fraction of genuinely new +messages** — and "some DMs just don't arrive" is the worst failure mode to debug. +There is none. + +Group events use `TimeUtils.now()` +([`MarmotOutboundDao.kt:578`](composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MarmotOutboundDao.kt:578)), +so a watermark would be safe there. It still is not used: `limit` already bounds +the initial burst, and a watermark would have to be recomputed every time a +chunk's membership changed. + +`limit` does the bounding instead, because NIP-01 scopes it to the initial query +— the stored events sent before EOSE — and explicitly not to the stream that +follows. So it caps what a reconnect costs without touching the live tail at all. +100 for gift wraps; 500 for a group chunk, which covers up to 100 conversations. + +`#h` is chunked 100 group ids to a subscription. One subscription per group would +be simpler, but every Marmot group here lives on the same DM relay set, so +carrying many ids in one filter costs a fraction of the subscriptions. + +### Following group membership + +The group filter is derived from `chatRepository.observeChatRoomListByPublicKey` +— the same Flow the chat list screen renders, collected at wallet scope so it +keeps running with no chat screen open. Rooms are filtered to those with MLS +state (a NIP-17 room has none and is served by the gift wrap subscription), minus +any we have left or deleted; ids are sorted, de-duplicated with +`distinctUntilChanged`, and debounced 500ms. + +Deriving it from the table rather than from the join sites is the decision that +keeps this correct, because a group id can appear four ways and only one of them +is somewhere anyone would think to call a subscribe function: + +- we create a group (`MarmotOutboundDao`), +- we are added to one — the Welcome arrives inside a gift wrap and is processed + deep inside `NostrDao.storeNostrEvent`, on the *inbound* path, +- membership shifts under us via a commit + (`MarmotInboundManager.processGroupMembershipChanges`), +- we leave, or the room is deleted. + +It also closes the loop: **a Welcome lands on the gift wrap subscription → a +`ChatRoom` row is written → the Flow re-emits → the group filter widens → the +first message in that new group arrives without anyone opening a chat.** + +Reconciling is an update, not a rebuild. A chunk that already has a subscription +gets a repeated REQ under the same id, so adding a group does not interrupt +delivery on the groups already being watched. Chunks that no longer exist are +`cancelAndJoin`ed — the CLOSE goes out from the coroutine's `finally`, and +returning before it lands would let a later reconcile open a subscription on that +id which the old one then closes out from under. + +### Lifecycle + +`AppLifecycle` is a singleton `StateFlow` fed by a `LifecycleEventObserver` +in `MantraNavHost`. A singleton rather than something threaded through the +composition, because the consumers are application-scoped coroutines that outlive +any screen. It defaults to foreground: on a platform where the observer is not +wired up, "always on" is the behaviour that predates it, and a subscription that +never opens is far worse than one that stays open too long. + +`collectLatest` over that flow is the whole mechanism. Backgrounding cancels the +block holding the subscriptions and each `finally` sends its CLOSE. + +Returning does two things before anything else: + +1. **`RelayPool.reconnectAll`** tears every socket down and immediately rebuilds + it. Trusting the connection is the mistake here — a socket that was open when + the OS suspended the process reports itself connected on the way back while + being functionally dead. Rebuilding eagerly rather than leaving it to the next + send is what makes it a *re*-connection, so retained subscriptions are replayed + and collectors still attached from before the gap resume. +2. **A catch-up reconciliation.** A live subscription answers "what is new since I + connected"; negentropy answers "what do you have that I don't". A background + gap is exactly the question only the second can answer — `limit` on the + re-opened subscriptions is a window, not a guarantee. + +### What negentropy is still for + +Live subscriptions replaced *polling*, not *reconciliation*. The queue and its +three pumps are unchanged, and negentropy remains the right tool for first +login, the foreground catch-up, "load older messages", and repairing a relay that +was unreachable while we were online. + +## Two things found while building this + +**The `"sent-messages"` filter could never match anything.** It asked for kind +1059 with `authors=[userPublicKey]`, but gift wraps are signed with a fresh +throwaway `KeyPair()`, so the pubkey is random and never ours. It was also +unnecessary: `createNip17ChatRoom` puts the user in their own participant list, so +we wrap a copy to ourselves and the account-wide `#p=[me]` subscription picks our +own sent messages up on every device. Deleted, in the view model and at both +sites in `NostrDao`. + +**The negentropy `saveNostrEvent` wrote outside `storeNostrEventMutex`** while +the other overload held it. `storeNostrEvent` reads an event and then writes it +and its indexes, so two of those interleaving is a lost update — survivable while +a single queue was the only writer, not survivable with a live subscription +writing alongside a backfill. + +## What is tested, and what is not + +Every failure mode in this subsystem is silent — a filter that drops messages, a +subscription that stops being replayed, a group id that never reaches an `#h` +tag. None of them throws, and all of them present as "some messages didn't +arrive", later, on someone else's phone. So the tests are aimed at the decisions +rather than at the plumbing: + +| file | pins | +|---|---| +| `RelayPoolSubscriptionTest` | retain on query, replay on reconnect, release on close, never replay negentropy, per-relay scoping — and that a live subscription keeps delivering after EOSE while a one-shot query still ends at it | +| `LiveSubscriptionReconcileTest` | the group filter following membership: widening in place rather than reopening, dropping a left group, closing when the last one goes, collapsing churn inside the debounce, and the CLOSED/back-pressure reopen behaviour | +| `LiveSubscriptionPlanTest` | the filter shapes, led by `since` being absent from the gift wrap filter | +| `RelayBackPressureTest`, `ReconnectBackoffTest` | the two pure decisions: which CLOSED reasons mean "ease off", and the backoff arithmetic including the exponent clamp | + +They were checked by mutation rather than by passing: reintroducing a `since`, +making EOSE terminal, dropping the `leftGroupAt` filter, removing retention from +`query()`, reconciling by close-and-reopen, removing the debounce and removing +the back-pressure branch each produce failures. + +Three things this does **not** cover, and could not without a real relay or a +database: + +- **`NostrSocketClientImpl` itself.** The reconnect loop, the session-identity + check on teardown, and the ordered inbound emission are exercised only through + their extracted arithmetic. Testing them wants a fake `WebSocketSession`. +- **Anything downstream of `saveNostrEvent`.** Indexing, gift wrap unwrapping, + Welcome handling and MLS decryption are Room-backed, and there is no sqlite + driver on the JVM test classpath (see the note in `build-verification-commands`). + The tests assert the event reaches the repository with the right relay and + level, and stop there. +- **The app on a device.** Nothing here proves a DM lands with the chat list + closed; that still wants the two manual checks — send yourself a message from + another client, and have a second device add you to a group. + +## Not done + +- **Connectivity changes.** A network switch mid-foreground is only noticed by + the socket's own reconnect loop, which handles the common case but cannot know + the network changed underneath it. Wisp's `RelayLifecycleManager` is the model: + observe connectivity, debounce, and suppress a network-change reconnect shortly + after a resume so the two do not fire twice. +- **NIP-42 AUTH.** A relay that answers CLOSED with `auth-required` is treated as + any other refusal and retried with backoff. It will never succeed. Worth + knowing before pointing this at a relay that requires auth. +- **A router.** The original design called for one permanent collector per socket + dispatching by subscription id prefix. With the inbound flow buffered and only a + handful of live subscriptions per relay, a collector per subscription has the + same properties for less machinery. If that count grows, the router is the next + move. +- **`Relays.DefaultDMRelayList` is one relay.** Every per-relay budget above is + currently a budget of one socket, which makes this cheap and makes a single + relay outage total. diff --git a/docs/marmot-direct-messages.md b/docs/marmot-direct-messages.md new file mode 100644 index 00000000..4314ce2d --- /dev/null +++ b/docs/marmot-direct-messages.md @@ -0,0 +1,371 @@ +# Direct messages inside a Marmot group + +A one-to-one message carried as an MLS application message: an ordinary NIP-59 gift +wrap, ephemeral-keyed and signed the way NIP-59 says, addressed to one member and +never broadcast to a relay. Every member of the group sees that a private message +was sent and to whom. Only the recipient can read it. + +Read [Why a throwaway key, and what it costs](#why-a-throwaway-key-and-what-it-costs) +before changing anything here. Two consequences of that choice reach into every +part of this, and one of them cannot be undone. + +## What a direct message is, outside in + +``` +kind:445 GroupEvent ephemeral signer, h-tag = nostrGroupId → relays +└─ ChaCha20-Poly1305 MLS exporter secret → group + └─ MLS PrivateMessage ContentType.APPLICATION → group + │ sender authenticated by MLS leaf ────────┐ + └─ kind:1059 wrap pubkey = throwaway, signed by it │ → group + └─ NIP-44 conversation key: throwaway ↔ recipient │ + └─ kind:13 seal signed by the sender ───── bound to ─────┘ + └─ NIP-44 → recipient + └─ kind:14 rumor → recipient +``` + +The group reaches the fourth layer and stops. Everything above it is what a +bystander renders from: a recipient, a time, a wall — and a sender who comes from +the MLS frame rather than from anything written in the wrap. + +The two outer layers are unchanged. A direct message is an ordinary kind:445 to +anyone watching a relay and an ordinary application message to `MlsGroup`; the +whole feature is what goes in as the application payload. + +`MarmotDirectMessage` builds and opens it, as pure functions of their arguments +with no database and no MLS state — which is what makes it testable, since +Room-backed code cannot be unit-tested in this project. + +## Why a throwaway key, and what it costs + +The wrap uses a fresh random key and is signed by it, exactly as NIP-59 specifies +and exactly as `GiftWrapEvent.create` builds one. Nothing in the wrap names the +sender. + +**What it does not buy: hiding the sender from the group.** MLS authenticates every +application message to a leaf. `mlsGroup.memberIdentityHex(decrypted.senderLeafIndex)` +yields the sender's real pubkey no matter what the inner event claims, and there is +no way to send an application message that does not. The throwaway key removes a +*redundant* copy of an identity the MLS frame already proves — it does not remove +the identity. + +**What it does buy:** a wrap lifted out of its MLS frame — a log line, a database +export, a crash dump — is not attributable to anyone. It is also a well-formed +NIP-59 gift wrap rather than a Marmot-shaped variant, which is worth something to +anyone reading the payload with ordinary nostr tooling. + +Two costs follow, and both are load-bearing. + +### It requires a carve-out in a security check + +`MarmotInboundManager.processPrivateMessage` rejects any inner application event +whose `pubKey` is not the MLS sender's credential identity: + +``` +MIP-03: inner event pubkey (…) does not match MLS sender identity (…) +``` + +That is MIP-03, not local policy, and it exists so a member cannot mint events +claiming a different author. A throwaway-keyed wrap cannot satisfy a check about +its author, so kind:1059 is exempt from it: + +```kotlin +if (innerEvent.kind != GiftWrapEvent.KIND && innerEvent.pubKey != senderIdentity) { + return GroupEventResult.Error(groupId, "MIP-03: inner event pubkey …") +} +``` + +`senderIdentity` is now *required* rather than merely compared. Under the old check +a null identity failed only because the comparison failed; attribution depends on +it outright, so it fails on its own. + +**The check is not weakened, it is relocated and strengthened.** What replaces it +for kind:1059 is `seal.pubKey == senderIdentity` on a seal whose `verify()` passes +— a signature bound to an MLS leaf, rather than a plaintext field compared to one. +The attack it stops is a member re-wrapping a seal they were legitimately sent and +passing it to a third party as its author's; that fails because the MLS frame says +who actually sent *this* one. Forging one outright needs the other member's private +key. + +A seal is harder to relabel than it looks, and not because of the signature. +NIP-44 derives the conversation key from the pubkey being claimed, so writing +another member's key over a seal makes it undecryptable by the person it was +encrypted for — it fails at the decryption, before any check runs, and comes out as +a message the recipient simply cannot read. The label is bound to the key rather +than asserted alongside it. `verify()` is what catches the remaining case: a seal +altered after signing in a way that survives decryption. Both have tests, because +the first was asserted the wrong way round until one of them failed. + +> **Compatibility.** Every member's client needs this carve-out. A Marmot client +> implementing MIP-03 as written drops these messages as impersonation — silently, +> as a `GroupEventResult.Error` — so a group with one unpatched member has one +> member who never receives a direct message and is never told why. This is a +> divergence from the spec, and a reason to raise it upstream rather than carry it +> indefinitely. + +### The sender cannot read their own messages back + +The throwaway private key is discarded at send time, so nothing can reopen the wrap +afterwards — not even the person who built it. NIP-17 solves this by sending a +second wrap addressed to yourself; here that would be a second application message, +and so a second *"sent a private message"* line in everyone else's transcript, so it +is not available. + +What follows: + +- The sending device keeps its own copy, because `sendChatMessage` writes the + plaintext `ChatMessage` row on the way out. Normal use is unaffected. +- A second device, or a reinstall, gets **nothing** — the sender's own half of + every conversation is unreadable to them anywhere it was not typed. The + recipient's half is unaffected. +- On a re-sync the sender's own message comes back as an opaque wrap they cannot + open, indistinguishable from a bystander's view. `ChatMessage.directMessage` + guards that case explicitly and files nothing, because a bystander line would + replace the words on the row `sendChatMessage` wrote — the only copy there is. + `NostrDao.persistInboundChatMessage` carries the same guard, for the same reason, + on the NIP-17 path. + +Do not solve this by storing the throwaway private key. A per-message private key at +rest is strictly worse than the identity-keyed wrap it was chosen over, and it +reintroduces the attribution the throwaway key exists to remove. There is a test — +`the sender cannot reopen their own message` — whose only job is to make that +reversal fail loudly rather than ship. + +## The one way to broadcast this by accident + +Nothing sweeps events to relays on its own. Broadcast is driven by +`BroadcastNostrEventRequest` rows joined to `NostrEvent`, inserted explicitly. So +the requirement "this must never be broadcast" reduces to one query: + +```sql +SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL +``` + +`GiftWrapPayloadDao.observeUnsealedGiftWrapPayloads`. The notary watches it, and +`DatabaseChatRepository.sealGiftWrapPayload` seals every row it returns and hands it +to `NostrNip17Dao.persistAndBroadcastGiftWrap` — which inserts a +`BroadcastNostrEventRequest` per relay. **Write one `GiftWrapPayload` row authored +by the local user with a null seal id and the direct message leaves the device.** + +Hence the rule: + +> A Marmot direct message never writes to `GiftWrapPayload` or `GiftWrapMessage` +> at all. Its outbound queue is `MarmotInnerEvent`, and so is its inbound record. + +The risk is sharper here than it would be with a Marmot-shaped payload, because what +this builds is a genuine, well-formed, correctly signed NIP-59 gift wrap. It is +indistinguishable from something the NIP-17 path would be right to publish. Nothing +but the tables it is kept out of stops it going to a relay. + +`GiftWrapMessage` could not be written anyway without a `NostrEvent` row — its +foreign key — and `NostrEvent` is the broadcast join target. The rule is also +enforced at the other end: `sealGiftWrapPayload` refuses any payload whose room has +a non-null `mlsGroupState`, and logs. An invariant in code is what stops a later +refactor from walking a direct message onto a relay without reading this page first. + +**The Welcome is exempt, and the exemption is not optional.** MIP-02 addresses +kind:444 to someone who is not yet in the group and holds no key to read a +kind:445 — a relay-borne gift wrap is the only way to reach them, and +`MarmotOutboundDao.deliveryWelcome` writes exactly such a `GiftWrapPayload` row on +purpose. Every Welcome carries its own room's `nostrGroupId`, so a refusal keyed on +`mlsGroupState` alone matches all of them: while it did, no invite to any Marmot +room was ever delivered, and — since the unsealed queue is a single-row flow — the +first refused Welcome sat at its head and blocked every payload queued behind it. +The check is therefore on kind first, room second. When adding a kind to that +exemption, the question to answer is whether its recipient can read a kind:445; if +they can, it does not belong on this path. + +## Attribution comes from MLS, not from the payload + +Because nothing in the wrap names the sender, every sender-derived field is read +from the MLS frame. This is not a workaround; it is the correct source, and it is +what makes the carve-out above safe. + +`GroupEventResult.ApplicationMessage` carries `senderLeafIndex` but not the resolved +identity, and `ChatMessage.fromGroupEventResult` has no `MlsGroup` to resolve it +with. The obvious fix — adding `senderIdentity` to the result — is not available: +quartz is a **binary dependency** here (`com.vitorpamplona.quartz:quartz:1.14.0`), +and the local checkout at `~/Documents/development/nostr/amethyst/quartz` is a +reference copy, not a build input. Changing the result type would mean publishing a +fork. + +So `NostrDao` resolves it at the call site instead and passes it in. It holds the +group, the leaf index is already on the result, and an application message advances +no epoch, so the tree has not moved by the time it reads it. Same value, no fork. + +This also settled a bug that predates the feature. All fourteen arms of +`fromGroupEventResult` computed: + +```kotlin +isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey +``` + +`groupEvent.pubKey` is the kind:445's signer, and that is a fresh random ephemeral +key on every send — `NostrSignerInternal(KeyPair())` in +`encryptAndSendMarmotInnerEvent` — so it could never equal the active key. It was +false for every Marmot message in every room, which meant every message a member +sent themselves rendered as somebody else's: wrong side of the transcript, wrong +colour, and no delivery status, which is drawn only for our own lines. All fourteen +now read `senderIdentity`. For the four results that carry no leaf index — the +commit and proposal statuses — it is null and yields false, exactly as before. + +## The message's identity is the rumor id + +The queued `MarmotInnerEvent` for a direct message **is the rumor** — kind:14, +plaintext, p-tagged — so its id is a hash of exactly the fields the recipient will +have after unwrapping. + +This matters in two places. It keeps the outbound link intact, which is the +difference between a message being sent and silently not being sent (see +[Two bugs this uncovered](#two-bugs-this-uncovered)). And it gives the recipient a +stable id for dedupe across redelivery and re-sync. + +It does **not** give sender and recipient a shared identity for the same message the +way an identity-keyed wrap would, because the sender never re-derives the rumor from +an echo they cannot open. Two devices belonging to the sender do not converge; they +simply do not both have the message. + +The wire event — the wrap — has its own id, so a direct message leaves two rows on a +recipient's device: one for the artifact that travelled and one for the message it +carried. + +## Timestamps are not fuzzed + +NIP-59 randomises the wrap's and the seal's `created_at` by up to two days to +frustrate correlation at a relay, and both `GiftWrapEvent.create` and +`SealedRumorEvent.create` default to `TimeUtils.randomWithTwoDays()`. + +The wrap never reaches a relay, and the kind:445 around it already carries the true +time. Fuzzing would do nothing but scatter the *"sent a private message"* line up to +two days out of position in every other member's transcript. `MarmotDirectMessage` +passes the real message time to all three layers — a default to override, not one to +accept, and the easiest thing here to get wrong by omission. There is a test for it. + +## Three decisions + +| decision | taken | what it costs | +|---|---|---| +| Is the recipient visible to the group? | yes — the `p` tag stays on the wrap | The group learns who messages whom, and how often. Omitting the tag is genuinely cheap here: the recipient decrypts against the wrap's own throwaway pubkey, so they need no tag to find their own mail, and one failed NIP-44 decrypt per member per message is the whole cost. What it takes is the named bystander line — *"sent a private message"* with no recipient — and any ordering by conversation. Worth revisiting; not free, but close. | +| Is the wrap signed? | yes — by the throwaway key, per NIP-59 | Nothing, which is the point. The signer is meaningless and discarded, so the signature attributes nothing, and keeping it means the payload is a real NIP-59 gift wrap that `GiftWrapEvent.create` builds and `unwrapOrNull` opens with no special cases. | +| What kind is the rumor? | `ChatMessageEvent.KIND` (14) | Group messages here are kind:9 (`ChatEvent`) — `sendChatMessage` maps 14 → 9 on the way in for an ordinary message and leaves a direct message at 14. It is a NIP-17 chat message that happens to travel inside a group, and the kind is what tells the two apart on the way back in. Costs one more arm in the inbound switch. | + +## The path a message takes + +**Out.** `sendChatMessage` resolves the recipient against the room's participants — +refusing a non-member, whose wrap nobody could open, and refusing yourself, whose +wrap could never be read back — and writes two rows: the queued `MarmotInnerEvent` +(the rumor) and the `ChatMessage` holding the plaintext. The notary picks the queued +row up, `MarmotOutboundDao` calls `MarmotDirectMessage.wrap` instead of assembling a +bare rumor, and everything downstream is unchanged. Once sent, the plaintext is +scrubbed from the queued row: it is already on the `ChatMessage`, and a second copy +would be cleartext left in a table that otherwise holds nothing but wire events. + +**In.** `MarmotInboundManager` decrypts and resolves the sender's identity, and +`MarmotDirectMessage.classify` decides what the wrap is to this device — ours, +readable, unreadable, or rejected. `ChatMessage.directMessage` turns that into rows. +The split is so the decision can be tested: only the filing needs a database, and +Room-backed code cannot be unit-tested here. The recipient opens the wrap, +validates the seal against `senderIdentity`, stores the rumor as its own +`MarmotInnerEvent` keyed on the rumor's id, and gets the words. A bystander cannot +open it and gets a line with no content. The sender gets nothing, because their +local row is the only copy. + +A failed validation drops the message and logs; it does not throw. The caller is +inside `storeNostrEvent`'s transaction, and a forged direct message should cost its +own line, not the whole event. + +**On screen.** Tapping somebody else's message in a group offers *Reply privately*, +which arms the composer; sending clears it. An armed composer shows a chip naming +the recipient, a placeholder that says who it is going to, and a tinted field, and +the chip's close button is the one tap back to the room. Three signals rather than +one because the failure this guards against — sending to the room what was meant for +one person, or the reverse — cannot be taken back once it is on the wire. + +## Two bugs this uncovered + +### The wrap's id is not the rumor's + +`encryptAndSendMarmotInnerEvent` found the chat message to link and broadcast with +`getChatMessagesByMarmotInnerEventId(innerEvent.id)` — the *recomputed* id. For a +direct message that is the wrap's id, not the queued rumor's, so the lookup returned +null, the `ChatMessage` was never linked, **and no `BroadcastNostrEventRequest` was +ever inserted**. The message would have been encrypted, persisted, and never sent, +with no error anywhere. + +It now keys on `marmotInnerEvent.id` — what `sendChatMessage` wrote into +`ChatMessage.marmotInnerEventId`, and identical to `innerEvent.id` for every +non-direct message, so nothing else moved. + +### `isUserMessage` was always false for Marmot messages + +Covered under [Attribution comes from MLS](#attribution-comes-from-mls-not-from-the-payload). +It stopped being merely cosmetic here: with nothing in the payload naming the +sender, `senderIdentity` is the only source of attribution there is. + +## Where it lives + +| file | what | +|---|---| +| `nostr/MarmotDirectMessage.kt` | wrap / open, the pure seam both directions call | +| `commonTest/.../MarmotDirectMessageTest.kt` | the envelope: ten cases, against real secp256k1 and real NIP-44 | +| `commonTest/.../MarmotDirectMessageDeliveryTest.kt` | what a device does with an arriving wrap, including every forgery it must refuse | +| `commonTest/.../MarmotMip03CarveOutTest.kt` | the kind:1059 exemption and its edges | +| `managers/MarmotInboundManager.kt` | the kind:1059 carve-out; requires the sender identity | +| `database/model/ChatMessage.kt` | `TYPE_DIRECT_MESSAGE`; the kind:1059 arm and its three outcomes | +| `database/dao/NostrDao.kt` | resolves `senderIdentity` and passes it in | +| `database/repository/DatabaseChatRepository.kt` | queues the rumor; refuses to seal an MLS room's payload, except a Welcome | +| `database/dao/MarmotOutboundDao.kt` | wraps on the way out; scrubs the plaintext | +| `database/model/MarmotInnerEvent.kt` | `directMessageRecipientPublicKey`, the outbound signal | +| `ui/view/model/ChatMessageListViewModel.kt` | armed state; the two renderings | +| `ui/composable/ChatRoomMessagingScreen.kt` | the recipient chip | + +Untouched, deliberately: `NostrNip17Dao`, `GiftWrapMessage`, `GiftWrapSeal`, +`GiftWrapPayload`, and every broadcast path. + +## What this does not do + +Each of these will be reported as a bug at some point. They are not. + +**The group learns that a direct message happened, and to whom.** Only the contents +are private, and the sender is authenticated by MLS whatever the wrap says. That is +the design, and the UI should say so in words somewhere a user meets before their +first private message — it does not yet. + +**The sender cannot read their own messages anywhere they were not typed.** The +throwaway key is gone. A reinstall or a second device recovers the recipient's half +of a conversation and none of its own. + +**Other Marmot clients drop these messages.** Until the carve-out is upstream, a +group needs every member on a client that carries it, and an unpatched member fails +silently. + +**The inner layer is not forward secret.** The kind:445 envelope inherits MLS epoch +forward secrecy; the NIP-44 layers inside do not. A recipient's identity key that +leaks opens every direct message they still hold, including ones sent years earlier. +This is strictly weaker than the group messages sitting beside them in the same +room. + +**A removed member keeps what they already have.** Removal advances the epoch; it +does not reach back into their device. + +**Disappearing messages apply at the envelope only.** The group's +`disappearingMessageSecs` puts a NIP-40 expiration on the kind:445, which relays +honour. Local rows are unaffected, exactly as for group messages today. + +**One recipient per message.** Several would mean several wraps, and so several +bystander lines for one message. Worth doing; worth designing first. + +**No reactions, receipts, replies or attachments.** A reply needs an `e` tag inside +the rumor and is a small addition later. A reaction is a design question rather than +a coding one, because the reaction itself would be visible to the group. + +**No member picker.** The only way to start a private message is to reply to one the +member already sent, so you cannot open a conversation with somebody who has not +spoken. + +**Three things have no automated test**, all of them for want of infrastructure +rather than by choice. The recipient validation in `sendChatMessage` (refusing a +non-member, refusing yourself) and the outbound id lookup both need a database; the +invariant the lookup depends on — that the queued row's id is the rumor's — is +tested in its place. The two transcript renderings need Compose UI testing, which +this project has no dependency on. Anything touching a real `MlsGroup` is likewise +untested: the carve-out is tested through `mip03Rejection`, not through a group. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c5a0d8b..0d36a928 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }