diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json index a21a6651..c387bc7a 100644 --- a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 21, - "identityHash": "ccbc98e82860c74663f3faed72cd1395", + "identityHash": "ff296ccd92cdb4bd61b3e150565f59c4", "entities": [ { "tableName": "BroadcastNostrEventReceipt", @@ -671,7 +671,7 @@ }, { "tableName": "ChatRoom", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `parentChatRoomId` TEXT, `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 )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -720,11 +720,6 @@ "columnName": "chronicleRequestedAt", "affinity": "INTEGER" }, - { - "fieldPath": "parentChatRoomId", - "columnName": "parentChatRoomId", - "affinity": "TEXT" - }, { "fieldPath": "createdAt", "columnName": "createdAt", @@ -1619,7 +1614,7 @@ }, { "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, `parentChatRoomId` TEXT, `birthCertificateJson` TEXT, `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 )", + "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", @@ -1657,16 +1652,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "parentChatRoomId", - "columnName": "parentChatRoomId", - "affinity": "TEXT" - }, - { - "fieldPath": "birthCertificateJson", - "columnName": "birthCertificateJson", - "affinity": "TEXT" - }, { "fieldPath": "createdAt", "columnName": "createdAt", @@ -1717,6 +1702,61 @@ } ] }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, { "tableName": "GroupSignedEvent", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -2541,410 +2581,6 @@ } ] }, - { - "tableName": "MantraProject", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`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": "description", - "columnName": "description", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "creatorPublicKey", - "columnName": "creatorPublicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ownerPublicKeys", - "columnName": "ownerPublicKeys", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "chatRoomId", - "columnName": "chatRoomId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "signature", - "columnName": "signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relayHint", - "columnName": "relayHint", - "affinity": "TEXT" - }, - { - "fieldPath": "marmotGroupEventId", - "columnName": "marmotGroupEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "groupSignedEventId", - "columnName": "groupSignedEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "nostrEventId", - "columnName": "nostrEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_MantraProject_chatRoomId", - "unique": false, - "columnNames": [ - "chatRoomId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" - } - ], - "foreignKeys": [ - { - "table": "ChatRoom", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatRoomId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "MantraProjectArtifactVersion", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , 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": "publicKey", - "columnName": "publicKey", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "projectId", - "columnName": "projectId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "artifactVersionId", - "columnName": "artifactVersionId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "chatRoomId", - "columnName": "chatRoomId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "signature", - "columnName": "signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relayHint", - "columnName": "relayHint", - "affinity": "TEXT" - }, - { - "fieldPath": "marmotGroupEventId", - "columnName": "marmotGroupEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "groupSignedEventId", - "columnName": "groupSignedEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "nostrEventId", - "columnName": "nostrEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_MantraProjectArtifactVersion_projectId", - "unique": false, - "columnNames": [ - "projectId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" - }, - { - "name": "index_MantraProjectArtifactVersion_artifactVersionId", - "unique": false, - "columnNames": [ - "artifactVersionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" - }, - { - "name": "index_MantraProjectArtifactVersion_chatRoomId", - "unique": false, - "columnNames": [ - "chatRoomId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" - } - ], - "foreignKeys": [ - { - "table": "MantraProject", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "projectId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "MantraArtifactVersion", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "artifactVersionId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "ChatRoom", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatRoomId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "MantraProjectTranslationArtifactVersion", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`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": "projectId", - "columnName": "projectId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "translationArtifactVersionId", - "columnName": "translationArtifactVersionId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "chatRoomId", - "columnName": "chatRoomId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "signature", - "columnName": "signature", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "relayHint", - "columnName": "relayHint", - "affinity": "TEXT" - }, - { - "fieldPath": "marmotGroupEventId", - "columnName": "marmotGroupEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "groupSignedEventId", - "columnName": "groupSignedEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "nostrEventId", - "columnName": "nostrEventId", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_MantraProjectTranslationArtifactVersion_projectId", - "unique": false, - "columnNames": [ - "projectId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" - }, - { - "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", - "unique": false, - "columnNames": [ - "translationArtifactVersionId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" - }, - { - "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", - "unique": false, - "columnNames": [ - "chatRoomId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" - } - ], - "foreignKeys": [ - { - "table": "MantraProject", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "projectId" - ], - "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": "MantraTranslation", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -6096,7 +5732,7 @@ ], "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, 'ccbc98e82860c74663f3faed72cd1395')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff296ccd92cdb4bd61b3e150565f59c4')" ] } } \ No newline at end of file diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json new file mode 100644 index 00000000..cd29f234 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/22.json @@ -0,0 +1,6142 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "eea30c363f8d70e31d7f5fa306650a58", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `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, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`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": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorPublicKey", + "columnName": "creatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKeys", + "columnName": "ownerPublicKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProject_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , 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": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_artifactVersionId", + "unique": false, + "columnNames": [ + "artifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`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": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectTranslationArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", + "unique": false, + "columnNames": [ + "translationArtifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "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": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eea30c363f8d70e31d7f5fa306650a58')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1d0e777a..5f5a9c07 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -514,4 +514,27 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Who adopts this group? + Which group should adopt this one? + No group you administer can adopt this one. A group can only be offered as a parent by somebody who is in it. + Asked that group to adopt this one + Parent groups + Add a parent group + %1$s has offered to adopt this group + Accept as a parent + A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them. + Asked this group to accept that parent + + Key ceremonies + This group has not held a key ceremony. + The group's shared key + A subgroup's key + A subgroup: %1$s + Waiting for members to join. + Waiting for everyone to commit their part. + Waiting for everyone to confirm the result. + The group has a key from this ceremony. + Abandoned. No key was created. + %1$s of %2$s members will sign with it + Set up a shared key 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 66a89663..c0c690ba 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -77,6 +77,7 @@ 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.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact @@ -145,6 +146,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) GiftWrapSeal::class, GiftWrapPayload::class, GroupKeyState::class, + GroupKeyStateParent::class, GroupSignedEvent::class, InReplyToRelation::class, MantraArtifact::class, @@ -186,7 +188,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 21, + version = 22, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -330,9 +332,25 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // which is what they were, and any identity may drain them. The broadcast // queue deliberately gets no owner: a signed event is anyone's to carry. AutoMigration(from = 19, to = 20), - // v21 adds the three project tables -- a project, and one row per source + // v21 moves a room's parentage off two GroupKeyState columns onto the new + // GroupKeyStateParent table, so a room can be the child of more than one + // group -- founded with one and adopted by another later. It copies + // before it drops, which no AutoMigration can express, and it drops + // ChatRoom.parentChatRoomId as well: that column was a cache of the + // verified value which nothing in production ever read, and keeping it + // would mean a column holding *one* of several parents, which is worse + // than unused. See MIGRATION_20_21 and + // docs/subgroups-multiple-parents.md. + // + // v22 adds the three project tables -- a project, and one row per source // version or translation that is part of it. Three new tables and - // nothing existing changes shape, so Room generates this itself. + // nothing existing changes shape, so Room generates this one itself. + // + // It is v22 rather than v21 because two trees allocated out of the same + // space in one release window: this landed as 21 on its own branch while + // the parentage table above took 21 on `mantra`. Renumbering was free -- + // nothing had shipped -- but see Phase 0 of docs/project-feature.md, + // which says to check, and checked the wrong tree. // // A project's contents are foreign keys into this room's own rows, with // CASCADE, like every other reference in this family. That is the @@ -345,7 +363,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // The owners are a comma-joined sorted string on the project row rather // than a fourth table, which is what DkgSession.participantPublicKeys // already does with a set of keys and for the same reason. - AutoMigration(from = 20, to = 21), + AutoMigration(from = 21, to = 22), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 99ff7ece..212e6c8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 +import press.mantra.compose.database.migrations.MIGRATION_20_21 import press.mantra.compose.database.migrations.MIGRATION_9_10 import press.mantra.compose.database.migrations.MIGRATION_13_14 import press.mantra.compose.database.migrations.MIGRATION_14_15 @@ -19,13 +20,21 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These four move data rather than + // Everything else Room generates itself. These five move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites // chat rows, 9->10 copies a session's per-event columns onto the items // table before dropping them, 13->14 renames a column and rewrites the - // chat rows that named it the old way, and 14->15 adds a column and deletes - // the chat rows written for messages from before this device joined. - .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15) + // chat rows that named it the old way, 14->15 adds a column and deletes + // the chat rows written for messages from before this device joined, and + // 20->21 copies each room's single parentage onto the table that can hold + // several before dropping the columns it came from. + .addMigrations( + MIGRATION_3_4, + MIGRATION_9_10, + MIGRATION_13_14, + MIGRATION_14_15, + MIGRATION_20_21 + ) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index c072cb30..c27e11bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -66,25 +66,14 @@ interface ChatRoomDao { ) fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow> - /** - * The subgroups of [parentChatRoomId] this device actually holds rooms for. - * - * Deliberately not the answer to "what subgroups does this group have" -- - * that is `SubgroupManager.subgroupsOf`, which reads the parent's signed - * certificates and so can name a child whose room has not been created, or - * was created for somebody else. This names the ones there is something to - * open. - * - * Soft-deleted rooms are excluded like everywhere else: a room the user has - * cleared away should not reappear because its parent lists it. - */ - @Transaction - @Query( - CHAT_ROOM_WITH_LAST_MESSAGE + - "WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " + - ORDER_BY_LAST_ACTIVITY - ) - fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + // "The subgroups of this room" used to be a column filter here, over a + // ChatRoom.parentChatRoomId that production never read -- the live answer + // went through GroupKeyState, and SubgroupManager.subgroupsOf reads the + // parent's own certificates, which can name a child whose room this device + // does not have. The column is gone at v21, because a room can have more + // than one parent and a single column would have held one of them. The + // rooms a parent's children occupy are reached through + // GroupKeyStateDao.getByParentChatRoomId and resolved from there. @Upsert suspend fun upsert(chatRoom: ChatRoom) 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 index 4a7f0b6f..f0076895 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -6,6 +6,8 @@ import androidx.room3.Transaction import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState @Dao abstract class GroupKeyStateDao { @@ -15,6 +17,18 @@ abstract class GroupKeyStateDao { @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") abstract fun observeByChatRoomId(chatRoomId: String): Flow + /** + * A state with the groups that have certified it, which is how a lineage + * should always be read -- the two are one signed statement. + */ + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState? + + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeLocalByChatRoomId(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 @@ -23,26 +37,52 @@ abstract class GroupKeyStateDao { * Every subgroup of [parentChatRoomId] this device holds a verified state * for. * - * Verified is the whole of what these rows are: a state only reaches the - * table with a parent on it once its birth certificate has been checked - * against that parent's signature, so this is a list of children the parent - * really did certify rather than a list of rooms claiming a parent. + * Verified is the whole of what these rows are: a parentage only reaches the + * table once its birth certificate has been checked against that parent's + * signature, so this is a list of children the parent really did certify + * rather than a list of rooms claiming a parent. * * It is not the whole list. A subgroup whose room this device does not have * leaves no state here at all, and the certificates in `GroupSignedEvent` are * the fuller answer -- see `SubgroupManager.subgroupsOf`. + * + * A child with two parents appears in both parents' answers, which is the + * point of the join: the row that makes it this parent's child says nothing + * about who else certified it. */ - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + + /** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */ + @Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List @Upsert abstract suspend fun upsert(groupKeyState: GroupKeyState) + @Upsert + abstract suspend fun upsertParents(parents: List) + + @Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String) + /** - * Files a state, keeping the newest one per room. + * Files a state and its whole lineage, keeping the newest one per room. * * This is where the event's replaceable semantics actually happen. Relays * never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is @@ -56,13 +96,36 @@ abstract class GroupKeyStateDao { * state agree by construction, since both derived it from the room they are * standing in. * - * Returns the state now on file. + * ### The lineage is replaced, not merged, and that is the whole reason this + * is a transaction + * + * A key state is replaceable and the newest wins **wholesale**, so the set of + * parents it names is the set -- not an addition to whatever was there. That + * is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and if these rows were merged rather than + * rewritten the dropped parent would survive the statement that dropped it. + * + * Deleting before inserting inside one transaction is also what stops a + * reader seeing a state beside somebody else's parents. Were the two writes + * separate, a concurrent `getLocalByChatRoomId` could land between them and + * read a state with no lineage, or with the old one -- both of which look + * exactly like a true answer. + * + * Returns the state now on file, with the lineage now on file beside it. */ @Transaction - open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + open suspend fun replace( + groupKeyState: GroupKeyState, + parents: List = emptyList(), + ): LocalGroupKeyState { val known = getByChatRoomId(groupKeyState.chatRoomId) - if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + if (known != null && known.announcedAt >= groupKeyState.announcedAt) { + return LocalGroupKeyState( + keyState = known, + parents = getParentsByChatRoomId(known.chatRoomId) + ) + } val stamped = groupKeyState.copy( createdAt = known?.createdAt ?: groupKeyState.createdAt, @@ -70,6 +133,13 @@ abstract class GroupKeyStateDao { ) upsert(stamped) - return stamped + // The state's own row has to exist before its parents', because they + // reference it. Room turns foreign keys off around a migration but not + // around a transaction, so the order here is load-bearing rather than + // tidy. + deleteParentsByChatRoomId(stamped.chatRoomId) + if (parents.isNotEmpty()) upsertParents(parents) + + return LocalGroupKeyState(keyState = stamped, parents = parents) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt new file mode 100644 index 00000000..e7869173 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a room's parentage off two `GroupKeyState` columns onto + * `GroupKeyStateParent`, so a room can be the child of more than one group. + * + * Not an `AutoMigration`. Room can create the table and it can drop the columns, + * but it cannot copy between them, and this migration is entirely about the copy. + * + * ### Every existing lineage is a set of one + * + * A group could only be founded with a parent, never adopted by a second, so + * every row that carries a parentage carries exactly one. Each becomes one row + * in the new table and nothing is lost -- and the rule the new primary key + * states, one certificate per child per parent, is already true of all of them. + * + * Rows with no parentage -- every group made before subgroups, and every + * top-level group made after -- contribute nothing, which is what "nobody's + * child" looks like in the new shape: no rows rather than a null. + * + * The `WHERE` takes both halves. A row with one and not the other should not + * exist, because `GroupKeyStateManager.stateFrom` has always dropped a state + * carrying a half-claim rather than filing it -- but this is the one moment the + * data is reshaped rather than re-derived, and a half row copied forward would + * become a parent whose certificate is the string "null". Better to leave such a + * row behind entirely: the state keeps saying what the room signs with, which is + * the part that matters, and loses a lineage it could never have proved. + * + * ### `ChatRoom.parentChatRoomId` goes with them, unread + * + * It was a cache of the verified value, written by `MarmotGroupCreation` and + * read -- in production -- by nothing at all; the live answer went through + * `GroupKeyState`. Keeping it would mean a column holding *one* of several + * parents, which is worse than unused: it is a cache that is wrong. So it is + * dropped rather than backfilled, and nothing is copied out of it. + * + * ### Why `DROP COLUMN` rather than a table rebuild + * + * The usual way to remove columns in SQLite -- create a new table, copy, drop + * the old one, rename -- is unsafe here and quietly so. `GroupKeyStateParent` + * references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE` + * fires cascades: with foreign keys enforced it would delete every parent row + * this migration had just written. Dropping `ChatRoom` would be far worse, since + * most of the schema cascades off it. + * + * `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and + * the columns must be free of indices and constraints -- all three are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. Both tables keep their keys, their foreign keys + * and their indices. + */ +val MIGRATION_20_21 = object : Migration(20, 21) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" + + "`chatRoomId` TEXT NOT NULL, " + + "`parentChatRoomId` TEXT NOT NULL, " + + "`birthCertificateJson` TEXT NOT NULL, " + + "PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " + + "ON `GroupKeyStateParent` (`parentChatRoomId`)" + ) + + connection.execSQL( + "INSERT OR IGNORE INTO `GroupKeyStateParent` " + + "(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " + + "SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " + + "FROM `GroupKeyState` " + + "WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL" + ) + + listOf("parentChatRoomId", "birthCertificateJson").forEach { column -> + connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`") + } + + connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`") + } +} 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 3552e6ca..34464476 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 @@ -386,6 +386,26 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * Another group has certified **this** room as its child. + * + * The other side of the line above, and it appears in the *child's* room. + * A group is founded with a parent and may be adopted by another later -- + * see docs/subgroups-multiple-parents.md -- and an adoption is two + * signings: the new parent certifies, then this room's own quorum + * re-announces its key state accepting it. The parent commits first, so + * this line is the offer, and it is the only notice the child's admins + * get before a proposal asks for their signature. + * + * **An offer rather than a fact about the lineage.** Nothing is adopted + * until this room's quorum says so, and nothing is filed: a child device + * cannot store an event signed by a group it is not in, and does not need + * to. The certificate itself travels inside the key state at the second + * signing. The wording has to carry that, or a member reads it as a + * relationship that already exists. + */ + const val TYPE_SUBGROUP_ADOPTION_OFFERED = "subgroupAdoptionOffered" + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -395,6 +415,7 @@ data class ChatMessage( */ val SUBGROUP_TYPES = setOf( TYPE_SUBGROUP_CERTIFIED, + TYPE_SUBGROUP_ADOPTION_OFFERED, ) /** @@ -1460,29 +1481,61 @@ data class ChatMessage( // that was ever put to this room -- the child may since have been // renamed, and this line is a record of what was agreed rather // than a view of what is. - SubgroupBirthCertificateEvent.KIND -> { - SubgroupManager.record( - database = database, - chatRoomId = groupId, - innerEvent = event - ) ?: return null + // + // Two true things a certificate can be, and the room it arrived + // in is what tells them apart -- see `SubgroupManager.classify`. + // This room signed it about another, or another signed it about + // this one, which is a parent offering to adopt a group that + // already exists. Anything else is nobody's business here. + SubgroupBirthCertificateEvent.KIND -> + when (val arriving = SubgroupManager.classify(groupId, event)) { + is SubgroupManager.ArrivingCertificate.OurChild -> { + SubgroupManager.record( + database = database, + chatRoomId = groupId, + innerEvent = event + ) ?: return null - val name = SubgroupBirthCertificateEvent.parseName(event.tags) - val admins = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size + val name = SubgroupBirthCertificateEvent.parseName(event.tags) + val admins = + SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_SUBGROUP_CERTIFIED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group made ${name ?: "a subgroup"} a subgroup, " + - "run by $admins of its members" - ) - } + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_CERTIFIED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group made ${name ?: "a subgroup"} a subgroup, " + + "run by $admins of its members" + ) + } + + // Deliberately writes a line and files nothing. The offer + // is real and verified -- that group's quorum signed it -- + // but this room is not adopted until its own quorum says + // so, and the certificate reaches these devices for real + // inside the key state that says it. + is SubgroupManager.ArrivingCertificate.OfferToUs -> ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_ADOPTION_OFFERED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "${arriving.parentChatRoomId.take(16)} has offered to " + + "adopt this group as a subgroup. It is a subgroup of theirs " + + "only once this group agrees what it signs with again, " + + "naming them." + ) + + null -> return null + } else -> { ChatMessage( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt index 20dc9cc5..8babc6ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt @@ -112,29 +112,6 @@ data class ChatRoom( */ val chronicleRequestedAt: Instant? = null, - /** - * The group this room is a subgroup of, or null for a room that is nobody's - * child. - * - * A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the - * room list and the room's own screen can answer "whose child is this" - * without a second read. It is written as the room is created or adopts its - * key state, and only from a state whose certificate passed - * `SubgroupBirthCertificateEvent.certifies`. - * - * **Never a foreign key onto `ChatRoom`.** Every other reference to a room in - * this schema cascades, and a self-referential cascade would mean deleting a - * parent silently deletes its subgroups -- and then, by their own cascades, - * those rooms' messages, participants, key states, signing sessions and - * signed events. A user tidying up a group they left would destroy a group - * they are still in. `RESTRICT` would be no better: it would make a parent - * undeletable while a child row exists, which is a foreign key deciding a - * product question. This routinely names a room this device does not have -- - * see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal, - * expected state. - */ - val parentChatRoomId: HexKey? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, 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 index 05800bf5..a3524da5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -94,41 +94,16 @@ data class GroupKeyState( val announcedAt: Instant, /** - * The group this one is a subgroup of, or null for a group that is nobody's - * child -- which is every group made before subgroups existed. + * A lineage used to live here, as a parent id and a certificate. It is + * `GroupKeyStateParent` now, one row per parent, because a room can have + * more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about + * what it means changed in the move, including that the parent side is never + * a foreign key onto `ChatRoom`; that entity's comment carries the argument. * - * **Not a foreign key, and that is deliberate.** The three tables around this - * one cascade off `ChatRoom`, and a self-referential cascade here would mean - * deleting a parent room deletes every subgroup's state beneath it. It could - * not be `RESTRICT` either: this routinely names a room the device does not - * have at all -- a member of a subgroup who was never in its parent holds the - * id and nothing else -- so a dangling value is the normal state and readers - * resolve it with a lookup allowed to return null. - * - * Written only from a state whose [birthCertificateJson] passed - * `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row - * where the parent's quorum really did sign for this room. See - * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than - * keeping a parentage it could not check. + * Read the two together through `LocalGroupKeyState`, never apart: a state + * and the lineage it claims are one signed statement, and + * `GroupKeyStateManager.stateFrom` believes all of it or drops all of it. */ - val parentChatRoomId: HexKey? = null, - - /** - * The parent's signed certificate, whole, as JSON -- or null when this group - * has no parent. - * - * The claim itself, where [parentChatRoomId] is only an index into it. Kept - * as the event rather than as its signature so that a reader can check it - * again from the row alone: a signature plus a rule for rebuilding the event - * it covers is a rule that breaks silently the first time the certificate's - * shape changes, and a rebuild differing by one byte hashes to an id whose - * signature fails and is indistinguishable from a forgery. - * - * Always set together with [parentChatRoomId]. One without the other is a - * state that never reached this table. - */ - val birthCertificateJson: String? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt new file mode 100644 index 00000000..8dda692d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One group that has certified a room as its child, and the signature saying so. + * + * A row per parent, because a room can have more than one: it is founded with + * one and may be adopted by another later, and an adoption is the parent + * certifying plus the child re-announcing its key state with the pair added. See + * `docs/subgroups-multiple-parents.md`. + * + * These rows were two nullable columns on [GroupKeyState] while a lineage could + * only ever be one link. Nothing about their meaning changed in the move. + * + * ### The primary key is the rule + * + * `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A + * second entry for a parent already in the set is one parent asked twice and is + * refused by `SubgroupLineage.of` before it gets here; the key is what makes + * that a property of the store rather than of a reader remembering. + * + * ### Verified before it is written, every time + * + * A row exists only where `SubgroupBirthCertificateEvent.certifies` has said + * that this parent's quorum really signed for this room -- see + * `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one + * parent's certificate fails. So the presence of a row is itself the claim that + * the lineage was checked, and there is no state in which some parents are + * believed and others are not. + * + * [birthCertificateJson] is the certificate whole rather than its signature, + * so a reader can check it again from the row alone. A signature plus a rule for + * rebuilding the event it covers is a rule that breaks silently the first time + * the certificate's shape changes: a rebuild differing by one byte hashes to an + * id whose signature fails and is indistinguishable from a forgery. + * + * ### The cascade points at the state, never at the parent's room + * + * `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from + * outliving the state that claimed it -- and it is what lets a re-announcement + * that drops a parent actually drop it, since `GroupKeyStateDao.replace` + * rewrites these rows inside the same transaction. + * + * [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A + * self-referential cascade would mean deleting a parent room deletes its + * children's key states, and then by their own cascades those rooms' messages, + * participants, signing sessions and signed events -- a user tidying up a group + * they left would destroy a group they are still in. `RESTRICT` would be no + * better: it would make a parent undeletable while a child row exists, which is + * a foreign key deciding a product question. And this routinely names a room the + * device does not have at all, since a member of a subgroup need never have been + * in its parent, so a dangling value is the normal, expected state and readers + * resolve it with a lookup allowed to return null. + */ +@Entity( + primaryKeys = ["chatRoomId", "parentChatRoomId"], + foreignKeys = [ + ForeignKey( + entity = GroupKeyState::class, + parentColumns = ["chatRoomId"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + // The composite primary key already indexes chatRoomId as its leftmost + // column, so only the other direction needs one: "which rooms has this + // group certified", which is how a parent finds the children whose state + // this device holds. + Index("parentChatRoomId"), + ], +) +data class GroupKeyStateParent( + /** The child: the room whose key state carries this parentage. */ + val chatRoomId: String, + + /** The parent's room id, which is also the key its certificate verifies against. */ + val parentChatRoomId: HexKey, + + /** The parent's signed certificate, whole, as JSON. */ + val birthCertificateJson: String, +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt new file mode 100644 index 00000000..30991c56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt @@ -0,0 +1,62 @@ +package press.mantra.compose.database.model.intermdiate + +import androidx.room3.Embedded +import androidx.room3.Relation +import com.vitorpamplona.quartz.nip01Core.core.Event +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage + +/** + * A room's key state with every group that has certified it as a child. + * + * The two always travel together, because a state and the lineage it claims are + * one signed statement: `GroupKeyStateManager.stateFrom` either believes all of + * it or drops all of it, so a reader holding the state without the parents would + * be holding half of something that has no halves. + * + * Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded` + * over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so + * the pure verifier can build one with no database anywhere near it. + */ +data class LocalGroupKeyState( + @Embedded + val keyState: GroupKeyState, + + @Relation( + entity = GroupKeyStateParent::class, + parentColumns = ["chatRoomId"], + entityColumns = ["chatRoomId"], + ) + val parents: List = emptyList(), +) { + /** + * The lineage these rows amount to, or null for a room that is nobody's + * child. + * + * Null also where the rows cannot be read back as a lineage -- a certificate + * whose JSON no longer parses, or a repeat the store should have made + * impossible. Both are the same answer here, and it is the safe one: a + * lineage that cannot be stated is not half-stated, for the reason + * `stateFrom` drops a whole state rather than keeping a parentage it could + * not check. + * + * Rebuilt rather than cached, so the canonical order and the no-repeats rule + * come from `SubgroupLineage` on every read instead of from whatever order + * the rows came back in. + */ + fun lineage(): SubgroupLineage? = SubgroupLineage.of( + parents.mapNotNull { parent -> + Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate -> + SubgroupParentage( + parentChatRoomId = parent.parentChatRoomId, + certificate = certificate + ) + } + }.takeIf { it.size == parents.size } ?: return null + ) + + /** The groups that have certified this room, in canonical order. */ + fun parentChatRoomIds(): List = lineage()?.parentChatRoomIds.orEmpty() +} 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 6b71b5e8..3463a4e5 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 @@ -55,7 +55,6 @@ class DatabaseChatRepository( userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.create( database = database, chatRepository = this, @@ -66,7 +65,6 @@ class DatabaseChatRepository( userPublicKey = userPublicKey, keyPair = keyPair, path = path, - parentChatRoomId = parentChatRoomId, ) override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey) @@ -113,11 +111,92 @@ class DatabaseChatRepository( emptyList() } - override suspend fun parentOf(chatRoomId: String): HexKey? = try { - SubgroupManager.parentOf(database, chatRoomId) + override suspend fun adoptionOffersFor(chatRoomId: String): List = try { + SubgroupManager.offersTo(database, chatRoomId) } catch (e: Throwable) { - logger.e("Error reading the parent of $chatRoomId", e) - null + logger.e("Error reading the adoption offers for $chatRoomId", e) + emptyList() + } + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = try { + SubgroupManager.adoptingCandidatesFor( + database = database, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error reading the groups that could adopt $chatRoomId", e) + emptyList() + } + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = try { + val adopting = database.chatRoomDao().findChatRoomById(adoptingChatRoomId) + ?: return "Couldn't load that group" + + SubgroupManager.refuseAdoption( + database = database, + adoptingRoom = adopting, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error checking whether $adoptingChatRoomId can adopt $chatRoomId", e) + // A refusal rather than a pass. A guard that could not run has not said + // yes, and the manager checks again anyway. + "Couldn't check whether that group can adopt this one" + } + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = try { + val adopting = checkNotNull(database.chatRoomDao().findChatRoomById(adoptingChatRoomId)) + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAdoption( + database = database, + adoptingRoom = adopting, + childRoom = child, + userPublicKey = userPublicKey + ) + true + } catch (e: Throwable) { + logger.e("Error asking $adoptingChatRoomId to adopt $chatRoomId", e) + false + } + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = try { + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAcceptance( + database = database, + childRoom = child, + userPublicKey = userPublicKey, + parentChatRoomId = parentChatRoomId + ) + true + } catch (e: Throwable) { + logger.e("Error asking $chatRoomId to accept $parentChatRoomId as a parent", e) + false + } + + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentsOf(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading the parents of $chatRoomId", e) + emptyList() } override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { 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 e1674fc5..fcb53e1f 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 @@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -110,29 +110,30 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + /** + * By session id, not by room. A ceremony room holds more than one ceremony + * the moment a subgroup's admins are the whole group, and a lookup by room + * answered about whichever the room's rule picked -- for a room holding only + * a subgroup's ceremony, asking for the room's *own* found nothing at all, + * and a finished subgroup with a signed key state could never be created. + */ + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + database.dkgSessionDao() + .getSessionById(dkgSessionId) + ?.thresholdPublicKey + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> - // The room this ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - // - // By session id, not by room. A ceremony room holds more than one - // ceremony the moment a subgroup's admins are the whole group, and - // a lookup by room answered about whichever the room's rule picked - // -- for a room holding only a subgroup's ceremony, asking for the - // room's *own* found nothing at all, and a finished subgroup with - // a signed key state could never be created. - val subjectRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - - subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + roomDerivedBy(dkgSessionId) + ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try { + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId) } catch (e: Throwable) { // The room is made and usable either way; what it loses is the shortcut @@ -176,14 +177,9 @@ class DatabaseDkgRepository( database.groupSignedEventDao() .observeByKind(SubgroupBirthCertificateEvent.KIND) .map { signedEvents -> - // The room the ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - val subgroupChatRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - ?: return@map null + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null signedEvents .filter { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 780d80e3..757221c8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -9,10 +9,13 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage /** @@ -112,7 +115,7 @@ object GroupKeyStateManager { userPublicKey: HexKey, key: DkgSession, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parent: SubgroupParentage? = null, + lineage: SubgroupLineage? = null, createdAt: Long = Clock.System.now().epochSeconds ): FrostSigningSession { val signingRoomId = localChatRoom.chatRoom.id @@ -137,9 +140,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = userPublicKey, - announcedAt = Instant.fromEpochSeconds(createdAt), - parentChatRoomId = parent?.parentChatRoomId, - birthCertificateJson = parent?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(createdAt) ) check(state.verifies()) { @@ -147,10 +148,14 @@ object GroupKeyStateManager { } // Refused here rather than discovered by the group. Every device that - // receives this state runs the same check in `parentageOf` and drops the - // state when it fails, so proposing one this device would not believe - // spends a quorum's attention on a statement nobody will keep. - if (parent != null) { + // receives this state runs the same checks in `lineageOf` and drops the + // state when any of them fails, so proposing one this device would not + // believe spends a quorum's attention on a statement nobody will keep. + // + // Every parent, because the receiving check is all-or-nothing: one bad + // certificate in a set of two drops the whole state, so a proposal is + // only worth making if every link in it holds. + lineage?.parents?.forEach { parent -> check( SubgroupBirthCertificateEvent.certifies( event = parent.certificate, @@ -176,7 +181,7 @@ object GroupKeyStateManager { chatRoomId = chatRoomId, dkgSessionId = key.id, path = path, - parent = parent + lineage = lineage ), content = thresholdPublicKey, key = key, @@ -201,18 +206,19 @@ object GroupKeyStateManager { database: MantraDatabase, chatRoomId: String, innerEvent: Event - ): GroupKeyState? { - val state = stateFrom(chatRoomId, innerEvent) ?: return null + ): LocalGroupKeyState? { + val local = stateFrom(chatRoomId, innerEvent) ?: return null + val announced = local.keyState.chatRoomId - if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) { + if (database.chatRoomDao().findChatRoomById(announced) == null) { logger.i( - "The group signed a key state for ${state.chatRoomId}, which does not exist " + + "The group signed a key state for $announced, which does not exist " + "yet; holding it until it does" ) return null } - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -235,12 +241,15 @@ object GroupKeyStateManager { * Idempotent: `replace` keeps the newest state per room, so adopting twice * settles on the same row. */ - suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? { - val state = signedStateFor(database, chatRoomId) ?: return null + suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? { + val local = signedStateFor(database, chatRoomId) ?: return null - logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}") + logger.i( + "Room $chatRoomId adopts the key state its group signed at " + + "${local.keyState.announcedAt}, naming ${local.parents.size} parents" + ) - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -253,7 +262,7 @@ object GroupKeyStateManager { * the ritual screen gates the "create the room" button on, and what [adopt] * files once there is a room to file it against. */ - suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? = stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId) /** @@ -282,7 +291,7 @@ object GroupKeyStateManager { * arrive can ask the same question of each emission -- which is how the * ceremony screen knows the group has finished agreeing. */ - fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? = + fun stateAmong(signedEvents: List, chatRoomId: String): LocalGroupKeyState? = signedAmong(signedEvents, chatRoomId)?.second /** @@ -297,7 +306,7 @@ object GroupKeyStateManager { private fun signedAmong( signedEvents: List, chatRoomId: String - ): Pair? = + ): Pair? = signedEvents .asSequence() .filter { it.kind == GroupKeyStateEvent.KIND } @@ -306,7 +315,7 @@ object GroupKeyStateManager { // every room at once, so a state that names no room names nothing. .filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId } .mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } } - .maxByOrNull { (_, state) -> state.announcedAt } + .maxByOrNull { (_, local) -> local.keyState.announcedAt } /** * The state an event amounts to, or null if it amounts to none. @@ -345,20 +354,21 @@ object GroupKeyStateManager { * names no room at all, and appears in the logs. */ /** - * A parentage that was checked, wrapping a value that is allowed to be + * A lineage that was checked, wrapping a value that is allowed to be * absent. * * Three outcomes have to be told apart and a bare nullable can only carry * two. A state may claim no parent, which is every group made before - * subgroups and every top-level group made after; it may claim one and prove - * it; or it may claim one it cannot prove, which is not a state with a field - * missing but a device asserting a relationship the parent never agreed to. - * The third has to stop [stateFrom] rather than be flattened into the first. + * subgroups and every top-level group made after; it may claim some and + * prove them all; or it may claim some it cannot prove, which is not a state + * with a field missing but a device asserting a relationship a parent never + * agreed to. The third has to stop [stateFrom] rather than be flattened into + * the first. * * So `null` here means refused, and a present [value] of `null` means none * claimed. */ - private class Parentage(val value: SubgroupParentage?) + private class Lineage(val value: SubgroupLineage?) /** * The parentage an event actually establishes, or null if it claims one it @@ -374,61 +384,80 @@ object GroupKeyStateManager { * not a state with one field wrong, and half-believing it would file a group * as top-level that its own members will see as a subgroup. */ - private fun parentageOf( + private fun lineageOf( announced: String, thresholdPublicKey: HexKey, innerEvent: Event - ): Parentage? { - if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null) + ): Lineage? { + if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null) - // Both or neither. A parent named with no certificate is a claim with the - // checkable part removed; a certificate with no parent named beside it - // has nothing to be an index of. Either alone is refused rather than - // completed from the other, because completing it would be this device - // deciding what the group meant. - val parentChatRoomId = GroupKeyStateEvent.parseParentChatRoomId(innerEvent.tags) - val certificate = GroupKeyStateEvent.parseBirthCertificate(innerEvent.tags) + // Both or neither, per parent, and no parent twice -- all of which + // `parseLineage` decides. A parent named with no certificate is a claim + // with the checkable part removed; a certificate with no parent named + // beside it has nothing to be an index of. Either alone is refused rather + // than completed from the other, because completing it would be this + // device deciding what the group meant. + val lineage = GroupKeyStateEvent.parseLineage(innerEvent.tags) - if (parentChatRoomId == null || certificate == null) { + if (lineage == null) { logger.w( - "Key state for $announced claims a parent in a form that cannot be read " + - "(parent=${parentChatRoomId != null}, certificate=${certificate != null}); dropping" + "Key state for $announced claims a parentage in a form that cannot be read; " + + "dropping" ) return null } - // The parent's own quorum, over this very room's id. Everything that - // makes a lineage worth anything is in here -- see - // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no - // key it has to be told. - if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { - logger.w( - "Key state for $announced carries no certificate by $parentChatRoomId for " + - "that room; dropping" - ) - return null + // Every parent, or none of them. One bad certificate drops the whole + // state rather than the one pair, for the reason the all-or-nothing rule + // was written: a state that claims a lineage it cannot back is a device + // asserting a relationship somebody never agreed to, and keeping the + // provable half would file a group as having one parent when its own + // members see two. A set that is partly believed is not a weaker set. + lineage.parents.forEach { parentage -> + val parentChatRoomId = parentage.parentChatRoomId + val certificate = parentage.certificate + + // The parent's own quorum, over this very room's id. Everything that + // makes a lineage worth anything is in here -- see + // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup, + // no key it has to be told, and no knowledge of the other parents. + // Each link is checked on its own, which is why a lineage can be + // assembled a parent at a time. + if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { + logger.w( + "Key state for $announced carries no certificate by $parentChatRoomId for " + + "that room; dropping" + ) + return null + } + + // Belt and braces: the id already derives from this key, and the + // certificate's id already derives from the key it names, so two + // certificates for one room cannot name two keys. Stated anyway + // because the two facts live in different places and a future change + // to either should have to notice this. + val certifiedKey = + SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) + if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { + logger.w( + "Key state for $announced names $thresholdPublicKey and the certificate " + + "by $parentChatRoomId names $certifiedKey; dropping" + ) + return null + } } - // Belt and braces: the id already derives from this key, and the - // certificate's id already derives from the key it names, so two - // certificates for one room cannot name two keys. Stated anyway because - // the two facts live in different places and a future change to either - // should have to notice this. - val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) - if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { - logger.w( - "Key state for $announced names $thresholdPublicKey and its certificate names " + - "$certifiedKey; dropping" - ) - return null - } - - return Parentage( - SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate) - ) + // Deliberately not checked: whether the parents know about each other. + // An earlier certificate cannot name a later co-parent -- the first + // parent signed before the second was a possibility -- so requiring them + // to agree on the set would make every adoption cost a fresh quorum in + // every group that had already certified this room. The child's key state + // is the sole authority on the set; each parent has a veto on being added + // and none on who else is in it. See docs/subgroups-multiple-parents.md. + return Lineage(lineage) } - fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? { val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) @@ -461,7 +490,7 @@ object GroupKeyStateManager { return null } - val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null + val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null val state = GroupKeyState( chatRoomId = announced, @@ -469,9 +498,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = innerEvent.pubKey, - announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt), - parentChatRoomId = parent.value?.parentChatRoomId, - birthCertificateJson = parent.value?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) ) // Half the trust model, in one line, and the half that does not care who @@ -484,6 +511,15 @@ object GroupKeyStateManager { return null } - return state + return LocalGroupKeyState( + keyState = state, + parents = lineage.value?.parents?.map { + GroupKeyStateParent( + chatRoomId = announced, + parentChatRoomId = it.parentChatRoomId, + birthCertificateJson = it.certificate.toJson() + ) + }.orEmpty() + ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt index c25a8c11..841bbff4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt @@ -93,10 +93,6 @@ object MarmotGroupCreation { * appended to it, because MIP-01 has no field for one and the path is what * rebuilds the `TweakCache` a signing session needs. * - * [parentChatRoomId] is written onto the room when this is a subgroup. It is - * the verified value off the child's own key state -- never a claim off a - * proposal. - * * [name] is put under [MarmotGroupName] here rather than trusted from the * caller. Every Marmot room on a device is a `#name`, and this is the funnel * both flows that make one already go through, so it is the one place the @@ -112,7 +108,6 @@ object MarmotGroupCreation { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT ): Outcome { chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) } @@ -163,15 +158,6 @@ object MarmotGroupCreation { mlsGroupState = group.saveState().encodeTls().toHex() ) ?: return Outcome.Failed("Couldn't create the group. Please try again.") - if (parentChatRoomId != null) { - // Written after the room exists rather than passed into its creation, - // because `getOrCreateChatRoom` is shared with every other way a room - // appears and none of them has a parent to hand it. - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId) - ) - } - // The room comes into existence already knowing what it signs with. The // group agreed that before any of this ran and this device has been // holding the signed statement since; this is simply the first moment diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 79c3ab7a..a10c237c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -11,6 +11,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage /** @@ -71,6 +72,18 @@ object SubgroupManager { */ const val MINIMUM_ADMINS: Int = 3 + /** + * How far an ancestor walk goes before it gives up. + * + * A lineage is a DAG rather than a tree once a room can have two parents, so + * "is this group already beneath that one" is a graph walk. Bounded because a + * cycle is constructible: a child's quorum can certify its own parent -- the + * parent's threshold key is public -- and the parent's quorum can re-announce + * a state accepting it. Both have to cooperate in something pointless, which + * is no reason to let a reader loop forever. + */ + const val MAXIMUM_LINEAGE_DEPTH: Int = 16 + /** * Why these admins cannot hold a ceremony together, or null when they can. * @@ -320,13 +333,414 @@ object SubgroupManager { userPublicKey = userPublicKey, key = key, path = path, - parent = SubgroupParentage( - parentChatRoomId = parentChatRoomId, - certificate = certificate.toEvent() + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) ) ) } + // ---- Adoption: a group that already exists gaining a parent ------------- + // + // Two signings and no ceremony, which is the whole of why this is cheap: the + // key exists, so there is no ChillDKG, and the room exists, so there is + // nothing to create. + // + // ``` + // 1. birth certificate, in the adopting group -> its quorum signs the child's id + // 2. key state, in the child itself -> the child's quorum re-announces, + // carrying every certificate it accepts + // ``` + // + // The order is forced, not chosen: the key state carries the certificate, so + // the certificate has to exist first. Which means **the parent commits first + // and the child decides second** -- step 1 is an offer, and step 2 is the + // acceptance. Picking a parent is not choosing on the child's behalf. + // + // Both steps need somebody in both groups, and there is no way around it: + // step 1 needs the child's threshold key, which only the child's members + // hold, and step 2 needs the certificate, which `recordSignedEvents` files + // only for the adopting group's members. They need not be the same person and + // their capabilities differ. See docs/subgroups-multiple-parents.md. + + /** + * Why [adoptingRoom] cannot offer to adopt [childChatRoomId], or null when it + * can. + * + * Read by the picker so nothing is offered that could only fail, and again + * before anything is published, because a screen not drawing something is not + * a guard. Each message says what to do rather than what is wrong. + * + * "This device" throughout means **the profile in the chair**, and + * `FrostSigningManager.canSign` does not currently tell the two apart -- see + * the note under docs/subgroups-multiple-parents.md. These refusals are + * honest about capability and silent about persona. + */ + suspend fun refuseAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childChatRoomId: String, + userPublicKey: HexKey + ): String? { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + + // The certificate is signed by the adopting group's key, so a device + // holding no share of it cannot open the session at all -- + // `proposeSigningBatch` throws outright, and throwing at a button is not + // a UI. First, because it is the one refusal the user cannot fix by + // picking differently. + if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) { + return "Only a member who holds a share of that group's key can adopt a group into it." + } + + // Read off the room's own `MarmotGroupData` rather than off + // `Participant.adminAt`, for the reason `refuseSubgroup` gives: that + // column is written only by `processGroupMembershipChanges` on an + // arriving commit, so on a room this device created it is null for + // everybody, including the creator. + val adoptingAdmins = runCatching { + adoptingRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull() + ?: return "A group can only adopt another if it has admins." + + if (!adoptingAdmins.contains(userPublicKey)) { + return "Only an admin of that group can adopt a group into it." + } + + if (adoptingChatRoomId.equals(childChatRoomId, ignoreCase = true)) { + return "A group cannot be its own parent." + } + + // The certificate carries the child's threshold key, and without it + // `certifies` fails its fourth question on every device including this + // one. That key lives on the child's own key state, which only the + // child's members hold -- so this is the overlap requirement showing up + // as a refusal. + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: return "This group has not agreed what it signs with, so there is nothing to certify." + + if (child.lineage()?.names(adoptingChatRoomId) == true) { + return "That group has already adopted this one." + } + + // One certificate per child per parent. A second is a `d`-tag replacement + // of the first rather than a second adoption, and spending a quorum's + // attention to restate something they have already signed is worse than + // doing nothing. + if (certificateFor(database, childChatRoomId, adoptingChatRoomId) != null) { + return "That group has already offered to adopt this one." + } + + // A cycle, as far as this device can see -- and only as far as that. The + // walk is bounded and visited-set guarded because a lineage is a DAG now + // rather than a tree: with one parent nothing was ever tempted to recurse. + if (isAncestorOf(database, childChatRoomId, adoptingChatRoomId)) { + return "That group is already a subgroup of this one." + } + + return null + } + + /** + * Whether [candidate] is [chatRoomId] or anything beneath it, as far as this + * device's own verified lineages reach. + * + * Bounded and visited-set guarded. A cycle is constructible -- two quorums + * cooperating in something pointless -- and more to the point a lineage is a + * DAG now, so the fan-out makes an unbounded walk worth refusing to write. + * + * Advisory. A device sees only the lineages it holds states for, so a false + * answer here is "I cannot see one", never "there is none". The real + * protection is that an adoption needs the child's own quorum. + */ + private suspend fun isAncestorOf( + database: MantraDatabase, + chatRoomId: String, + candidate: String, + depth: Int = MAXIMUM_LINEAGE_DEPTH + ): Boolean { + val seen = mutableSetOf(chatRoomId.lowercase()) + var frontier = listOf(chatRoomId) + + repeat(depth) { + if (frontier.isEmpty()) return false + + val children = frontier.flatMap { parent -> + database.groupKeyStateDao().getByParentChatRoomId(parent) + .map { it.keyState.chatRoomId } + } + + if (children.any { it.equals(candidate, ignoreCase = true) }) return true + + frontier = children.filter { seen.add(it.lowercase()) } + } + + return false + } + + /** + * Groups that have offered to adopt [chatRoomId] and are not its parents yet, + * newest offer first. + * + * Read off every certificate this device holds, in any room, rather than off + * the room this one is -- a certificate about this group is signed in the + * *other* group's room, so it is filed against that room and would never be + * found by asking this one. Each is checked by [classify], which verifies the + * signature against the group it names and needs no lookup to do it. + * + * Already-parents are dropped, because an offer that has been accepted is + * not an offer any more; the lineage is where that shows. + */ + suspend fun offersTo(database: MantraDatabase, chatRoomId: String): List { + val accepted = parentsOf(database, chatRoomId).map { it.lowercase() }.toSet() + + return database.groupSignedEventDao() + .getByKind(SubgroupBirthCertificateEvent.KIND) + .sortedByDescending { it.createdAt } + .mapNotNull { signed -> + (classify(chatRoomId, signed.toEvent()) + as? ArrivingCertificate.OfferToUs)?.parentChatRoomId + } + .filterNot { it.lowercase() in accepted } + .distinctBy { it.lowercase() } + } + + /** + * The groups this profile could offer [childChatRoomId] to, in room order. + * + * **The active profile's own rooms, and it cannot be anything else.** A + * certificate is proposed *inside* the adopting group, so a candidate has to + * be a room the key in the chair can already act in. Seeded from + * `getChatRoomListByUserPublicKey`, which is keyed by identity: since several + * profiles landed a device can hold rooms it must not offer here, because the + * key that would have to sign is not the one acting. + * + * A group cannot be offered as a parent by somebody who is not in it. That is + * the overlap requirement showing up as a list length, and an empty answer is + * a fact about this member rather than a failure -- the remedy is a person, + * not a button. + * + * Filtered through [refuseAdoption] itself rather than through a copy of its + * rules, so the picker and the manager cannot drift. The cost is one MLS + * decode per room, to reach `adminPubkeys`, plus two indexed reads; call it + * off the main thread. + */ + suspend fun adoptingCandidatesFor( + database: MantraDatabase, + childChatRoomId: String, + userPublicKey: HexKey + ): List = + database.chatRoomDao().getChatRoomListByUserPublicKey(userPublicKey) + .filter { candidate -> + refuseAdoption( + database = database, + adoptingRoom = candidate, + childChatRoomId = childChatRoomId, + userPublicKey = userPublicKey + ) == null + } + + /** + * Step 1: asks [adoptingRoom]'s quorum to certify an existing group as its + * child. + * + * The same event kind, the same six checks on the other side, and the same + * room it has always been proposed in -- what differs from founding is only + * that the child is already there, so its id and key are read off its key + * state rather than derived from a ceremony this device may not hold. + * + * **The roster and the name are the child's current ones**, and that is the + * honest value rather than a compromise. On a founding certificate they are + * the founding roster, frozen, because there is nothing else they could be. + * Here the adopting group's admins are being asked about a group that exists, + * and what they can weigh is who runs it now and what it is called now. They + * are still frozen the moment they are signed, and still never a live roster: + * `certifies` does not check either, for the reason it never did. + */ + suspend fun proposeAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childRoom: LocalChatRoom, + userPublicKey: HexKey + ): FrostSigningSession { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + val childChatRoomId = childRoom.chatRoom.id + + refuseAdoption(database, adoptingRoom, childChatRoomId, userPublicKey)?.let { + throw IllegalStateException(it) + } + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException("$childChatRoomId has agreed no key state to certify") + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val childAdmins = runCatching { + childRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull().orEmpty() + + val childName = MarmotGroupName.of(childRoom.chatRoom.subject.orEmpty().ifBlank { "a group" }) + + logger.i("Asking $adoptingChatRoomId to adopt $childChatRoomId as its subgroup") + + return FrostSigningManager.proposeSigning( + database = database, + localChatRoom = adoptingRoom, + userPublicKey = userPublicKey, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = childChatRoomId, + parentChatRoomId = adoptingChatRoomId, + thresholdPublicKey = child.keyState.thresholdPublicKey, + adminPublicKeys = childAdmins, + name = childName, + path = path + ), + content = childChatRoomId + ) + } + + /** + * Step 2: asks the child's own quorum to accept [parentChatRoomId], by + * re-announcing its key state with that parentage added. + * + * The acceptance, and the only half of an adoption that decides anything. The + * certificate is an offer until this is signed, which is why + * `ProposedEvent`'s 30326 arm leads with the parents -- this proposal is + * where a member of the child answers it. + * + * **Carries the resulting set, not the addition.** A key state is replaceable + * and the newest wins wholesale, so a state naming only the new parent would + * silently drop every existing one. The current lineage is read off the row + * here rather than taken from the caller, and `SubgroupLineage.plus` is what + * makes adding an addition. + * + * Signed in the child's **own** room, which it can be because the room + * exists: `GroupKeyStateManager.propose`'s first branch holds, and + * `FrostSigningManager.signingPath` resolves off the child's own key state. + * Founding could not do either, which is why its key state is signed where + * its ceremony ran. + */ + suspend fun proposeAcceptance( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + // The certificate this device holds for the pair. Only a member of the + // adopting group has one -- `recordSignedEvents` files it against that + // group's room -- so this is the overlap requirement again, from the + // other side. + val certificate = certificateFor(database, childChatRoomId, parentChatRoomId) + ?: throw IllegalStateException( + "$parentChatRoomId has not certified $childChatRoomId, so there is nothing to accept" + ) + + val parentage = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) + + val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage) + checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + logger.i( + "Asking $childChatRoomId to accept $parentChatRoomId, making " + + "${lineage.size} parents in all" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = lineage + ) + } + + /** + * Asks the child's own quorum to stop claiming [parentChatRoomId], by + * re-announcing its key state without that parentage. + * + * The same act as [proposeAcceptance] with the set the other way, and the + * reason `SubgroupLineage.minus` exists. It **revokes nothing**: a certificate + * is a signature and exists forever, and what this removes is the child's half + * of the link -- which is the half that decides, since a lineage is what the + * child's quorum most recently declared. + * + * The parent's own list does not follow. `subgroupsOf` reads that group's + * certificates, so it will go on naming this room; a parent device that also + * holds this room's key state can see that it no longer names them, and one + * that does not cannot and must not guess. + */ + suspend fun proposeDisavowal( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val current = child.lineage() + check(current?.names(parentChatRoomId) == true) { + "$childChatRoomId does not name $parentChatRoomId as a parent" + } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + val remaining = current.minus(parentChatRoomId) + + logger.i( + "Asking $childChatRoomId to stop claiming $parentChatRoomId, leaving " + + "${remaining?.size ?: 0} parents" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = remaining + ) + } + /** * A subgroup as the parent's own record has it, plus whatever this device * happens to hold about it. @@ -407,18 +821,99 @@ object SubgroupManager { .sortedByDescending { it.certificate.createdAt } /** - * The group a room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified a room as its child, verified, in canonical + * order -- empty for a room that is nobody's child. * - * Off the `GroupKeyState`, which only carries a parent whose certificate was - * checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this - * device does not hold answers null even when it really is a subgroup, which - * is the position a member welcomed in after the founding is in; the - * unverified hint in the room's own description is the most they get, and it - * must never be written here. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked against that parent's own signature -- see + * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than + * keeping a lineage it could not check. So every id here is a group that + * really did certify this room. + * + * More than one is the ordinary case now, not a corner: a group is founded + * with one parent and may be adopted by another later. The order is the + * lineage's own and says nothing about which came first. + * + * A room whose state this device does not hold answers empty even when it + * really is a subgroup, which is the position a member welcomed in after the + * founding is in. Nothing unverified may be written here. */ - suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? = - database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId + suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List = + database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() + .orEmpty() + + /** + * What a certificate arriving in a room is, or null if it is nothing this + * room should believe. + * + * Pure, and the decision the 30329 dispatch arm cannot make for itself. Two + * true things a certificate can be, and the room it arrived in is what tells + * them apart: + * + * - [OurChild] -- **this** room signed it, about another room. The founding + * case, and the one that was always here. + * - [OfferToUs] -- it is about **this** room, signed by the group it names. + * A parent offering to adopt an existing group, which is the half of an + * adoption the child's members would otherwise never hear about until a + * proposal asked for their signature. + * + * Both are fully verified before they are returned, and neither needs a + * lookup: `certifies` checks a signature against a room id, and a room id is + * a public key. So an offer can be believed on its own merits by a device that + * has never held the offering group's room. + * + * **Neither is stored as the offering group's event.** `GroupSignedEvent` + * declares a foreign key onto `ChatRoom`, so a child device with no row for + * the parent could not file one if it wanted to. It does not need to: the + * certificate reaches the child's devices for real inside the key state the + * child's own quorum signs, and what an offer buys here is notice, not + * evidence. + */ + sealed interface ArrivingCertificate { + /** This room certified [subgroupChatRoomId] as its child. */ + data class OurChild(val subgroupChatRoomId: String) : ArrivingCertificate + + /** [parentChatRoomId] has certified this room as *its* child. */ + data class OfferToUs(val parentChatRoomId: HexKey) : ArrivingCertificate + } + + fun classify(chatRoomId: String, innerEvent: Event): ArrivingCertificate? { + val subgroupChatRoomId = + SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags) + ?: return null + + // A certificate names a parent or it is not one; `certifies` would refuse + // it either way, so reading the tag first costs nothing and lets the next + // check come before anything else. + val parentChatRoomId = + SubgroupBirthCertificateEvent.parseParentChatRoomId(innerEvent.tags) ?: return null + + // **A group is not its own child**, and this is the one self-referential + // certificate `certifies` waves through: a group can sign, truthfully by + // every check that function makes, that it is its own parent -- the + // author, the subject and the named parent are one room, the id derives + // from the key, and the signature verifies. It means nothing, and it has + // to be refused before either branch below rather than inside one, or the + // first branch reads it as an ordinary child and the group appears in its + // own list of subgroups. + if (subgroupChatRoomId.equals(parentChatRoomId, ignoreCase = true)) return null + + if (SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) { + return ArrivingCertificate.OurChild(subgroupChatRoomId) + } + + // Only a certificate about *this* room can be an offer to it. Checked + // before the signature so that a certificate about some third room, + // signed by some fourth, cannot be read as anything at all -- it is + // nobody's business in this room whoever signed it. + if (!subgroupChatRoomId.equals(chatRoomId, ignoreCase = true)) return null + + if (!SubgroupBirthCertificateEvent.certifies(innerEvent, chatRoomId, parentChatRoomId)) { + return null + } + + return ArrivingCertificate.OfferToUs(parentChatRoomId) + } /** * Files a certificate that arrived, or drops it and says why. 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 index 2f2d9bd3..70ec49c9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -11,6 +11,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag import press.mantra.compose.nostr.frost.tags.FrostKeyTag import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag @@ -110,46 +111,94 @@ object GroupKeyStateEvent { chatRoomId: String, dkgSessionId: String, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parent: SubgroupParentage? = null + lineage: SubgroupLineage? = null ): Array> = arrayOf( DTag.assemble(chatRoomId), FrostKeyTag.assemble(dkgSessionId), FrostDerivationPathTag.assemble(path) ) + ( - parent?.let { - // Both or neither, which is why this arrives as one value rather - // than as two nullable parameters a caller could half-fill. A state - // carrying one without the other is dropped on read; emitting one is - // therefore only a way to make a state nobody will believe. - arrayOf( + lineage?.parents?.flatMap { + // Both or neither, and **adjacent**: one parent's id immediately + // followed by that parent's certificate, in the canonical order + // `SubgroupLineage` sorts them into. A state carrying one half + // without the other is dropped on read, so emitting one is only a + // way to make a state nobody will believe. + // + // The pairing is what a build predating several parents depends on. + // Such a reader takes the first tag of each name and checks them + // against each other: emitted in pairs it gets one real parent and + // one real certificate for it, verifies them, and files a true but + // partial lineage. Emitted as all the ids and then all the + // certificates it would pair the first id with a certificate that + // may belong to another parent, fail `certifies`, and drop the whole + // state -- losing the row that says which ceremony's share signs for + // this room, which is a worse outcome than not knowing about the + // second parent. See `SubgroupLineage`. + listOf( SubgroupParentTag.assemble(it.parentChatRoomId), SubgroupBirthCertificateTag.assemble(it.certificate) ) - } ?: emptyArray() + }?.toTypedArray() ?: emptyArray() ) /** - * The group this one claims to be a subgroup of, or null if it claims none. + * The lineage the tags claim, or null if they claim none or claim one that + * cannot be read. * - * A *claim* until [SubgroupBirthCertificateEvent.certifies] says otherwise. - * Read alongside [parseBirthCertificate] and never on its own -- see - * `GroupKeyStateManager.stateFrom`. + * The two outcomes share a null for the reason every parentage reader here + * does: a tag that is present and unparseable looks exactly like an absent + * one through a parser. [claimsParentage] is what tells them apart, and + * `GroupKeyStateManager.stateFrom` asks it first -- a state claiming a parent + * in a form nothing can check is refused rather than filed as top-level. + * + * Unreadable here means any of: an odd number of halves, a tag of either + * name that does not parse, or the same parent named twice. Each is a lineage + * this device cannot state back, and completing one from the other halves + * would be this device deciding what the group meant. + * + * **Pairing is positional.** The *n*th `parent_group` goes with the *n*th + * `birth_certificate`, which is how [assembleTags] writes them and how a + * reader that only ever looks at the first of each reads them. Nothing is + * matched up by looking inside the certificates: whether the pair agrees is + * `SubgroupBirthCertificateEvent.certifies`' question, asked once per pair, + * and a mismatched pair must fail it rather than be quietly re-paired into + * one that passes. */ - fun parseParentChatRoomId(tags: Array>): HexKey? = - tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId + fun parseLineage(tags: Array>): SubgroupLineage? { + val parentTagCount = tags.count { it.isNotEmpty() && it[0] == SubgroupParentTag.TAG_NAME } + val certificateTagCount = + tags.count { it.isNotEmpty() && it[0] == SubgroupBirthCertificateTag.TAG_NAME } - /** The parent's signed certificate, as it signed it, or null if it carries none. */ - fun parseBirthCertificate(tags: Array>): Event? = - tags.firstNotNullOfOrNull(SubgroupBirthCertificateTag::parse)?.certificate + if (parentTagCount == 0 || parentTagCount != certificateTagCount) return null + + val parentChatRoomIds = tags.mapNotNull(SubgroupParentTag::parse) + .map { it.parentChatRoomId } + val certificates = tags.mapNotNull(SubgroupBirthCertificateTag::parse) + .map { it.certificate } + + // Counted by tag name above and by successful parse here, so a half that + // is present and broken is told from one that was never written. Only + // the first would shift the pairing. + if (parentChatRoomIds.size != parentTagCount) return null + if (certificates.size != certificateTagCount) return null + + return SubgroupLineage.of( + parentChatRoomIds.zip(certificates) { parentChatRoomId, certificate -> + SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate + ) + } + ) + } /** - * Whether the tags carry either half of a parentage. + * Whether the tags carry any half of a parentage. * - * The question [parseParentChatRoomId] and [parseBirthCertificate] cannot - * answer between them: a tag that is present and unparseable reads as absent - * through both. A state claiming a parent in a form nothing can check must be - * refused rather than quietly filed as an ordinary top-level group, so this - * looks at the tag names alone. + * The question [parseLineage] cannot answer, because a tag that is present + * and unparseable reads as absent through it. A state claiming a parent in a + * form nothing can check must be refused rather than quietly filed as an + * ordinary top-level group, so this looks at the tag names alone. */ fun claimsParentage(tags: Array>): Boolean = tags.any { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt index 9c1f8155..0554151d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent { subgroupChatRoomId: String, parentChatRoomId: String ): Boolean = runCatching { - if (event.kind != KIND) return false - - if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false - - val addressed = parseSubgroupChatRoomId(event.tags) ?: return false - if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false - - val named = parseParentChatRoomId(event.tags) ?: return false - if (!named.equals(parentChatRoomId, ignoreCase = true)) return false + if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false val path = parsePath(event.tags) ?: return false @@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent { GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId) }.getOrDefault(false) + /** + * Whether this event is *addressed to* that child by that parent -- the + * half of [certifies] that can be asked of a proposal nobody has signed. + * + * **Not a weaker [certifies].** It says who the event is about, not that + * the parent's quorum agreed to it: no derivation and no signature, so an + * event that merely claims the right pair passes. Nothing may decide a + * lineage on this. + * + * What it is for is telling one *in-flight* certificate signing from + * another in the same room. A parent certifies every subgroup it ever makes + * from one room, so the ceremony screen has several to choose between and + * no signature to choose by -- the proposal is what the quorum has not yet + * signed. The three values it reads are assembled together by + * [assembleTags], so they agree by construction. + */ + fun addresses( + event: Event, + subgroupChatRoomId: String, + parentChatRoomId: String + ): Boolean = runCatching { + if (event.kind != KIND) return false + + if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false + + val addressed = parseSubgroupChatRoomId(event.tags) ?: return false + if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false + + val named = parseParentChatRoomId(event.tags) ?: return false + named.equals(parentChatRoomId, ignoreCase = true) + }.getOrDefault(false) + /** * The name tag. A bare two-element tag with no class of its own, because * nothing but the proposal screen and the subgroup list ever reads it and diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt new file mode 100644 index 00000000..a4d7daa0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt @@ -0,0 +1,108 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Every group a room is a child of, each with the signature saying so. + * + * A room's lineage is a *set*, and this is the type that makes it one. A group + * is founded with one parent and may be adopted by another later -- see + * `docs/subgroups-multiple-parents.md` -- so "the parent" was never a field so + * much as a set that had only ever held one thing. + * + * ### Why a type rather than a list + * + * Two invariants have to hold wherever a lineage is written, and neither is the + * sort a caller can be trusted to remember. + * + * **No parent appears twice.** Two entries naming one group is one parent asked + * twice, and a set with a repeat in it is not a set. [of] refuses rather than + * folding them, because the two entries carry two certificates and picking one + * would be this device deciding which of the parent's statements counts. + * + * **The order is canonical.** Sorted by parent id, so two devices assembling the + * same lineage from the same certificates emit the same bytes. That matters for + * one reason only, and it is a reason about other people's builds: a reader that + * predates several parents takes the *first* `parent_group` and the *first* + * `birth_certificate` off the tags and checks them against each other. Emitted + * in sorted pairs, such a reader files a true, partial lineage -- one parent, + * verified. Emitted in two unordered runs it would pair one parent's id with + * another's certificate, fail the check, and drop the whole state, losing the row + * that says which ceremony's share signs for the room. See + * `GroupKeyStateEvent.assembleTags`. + * + * ### Nothing here is evidence + * + * Holding one of these says only that somebody assembled it. Every certificate + * in it is checked by `SubgroupBirthCertificateEvent.certifies` on every read -- + * see `GroupKeyStateManager.stateFrom`, which drops the whole state when any one + * of them fails. This type holds the shape; the signatures hold the trust. + */ +class SubgroupLineage private constructor( + /** The parents, in canonical order. Never empty, never repeating. */ + val parents: List, +) { + /** The parents' room ids, in the same canonical order. */ + val parentChatRoomIds: List get() = parents.map { it.parentChatRoomId } + + /** How many groups claim this room as a child. At least one. */ + val size: Int get() = parents.size + + /** Whether [parentChatRoomId] is one of them. Hex, so case does not count. */ + fun names(parentChatRoomId: HexKey): Boolean = + parents.any { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) } + + /** + * This lineage with [parent] added, or null if it already names that group. + * + * An adoption, as the child's quorum will sign it. It takes the whole + * resulting set rather than the one addition because a key state is + * replaceable and the newest wins *wholesale* -- so a re-announcement that + * carried only the new parent would silently drop the old one. Expressing the + * addition as an addition is what stops a caller assembling that by hand and + * forgetting. + */ + fun plus(parent: SubgroupParentage): SubgroupLineage? = + if (names(parent.parentChatRoomId)) null else of(parents + parent) + + /** + * This lineage without [parentChatRoomId], or null when it was the last one. + * + * Null is "no lineage" rather than a failure, and is what a caller passes on + * to say a room is nobody's child. Dropping a parent that is not named + * returns this lineage unchanged, since the result is the same either way. + * + * It does not revoke anything. The certificate is a signature and exists + * forever; what this removes is the child's half of the link, which is the + * half that decides. The parent's own list is built from its certificates and + * will go on naming the child -- see `SubgroupManager.subgroupsOf`. + */ + fun minus(parentChatRoomId: HexKey): SubgroupLineage? = of( + parents.filterNot { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) } + ) + + companion object { + /** + * A lineage of [parents], or null if there are none or one repeats. + * + * Null for empty is deliberate and is the same null every parentage API + * here uses for "nobody's child": a room with no parents has no lineage + * rather than an empty one, so there is one way to say it instead of two. + */ + fun of(parents: List): SubgroupLineage? { + if (parents.isEmpty()) return null + + val distinct = parents.distinctBy { it.parentChatRoomId.lowercase() } + if (distinct.size != parents.size) return null + + return SubgroupLineage(distinct.sortedBy { it.parentChatRoomId.lowercase() }) + } + + /** + * A lineage of one, which cannot fail -- a single entry has nothing to + * repeat. The founding case, and the shape every state signed before + * several parents existed is read back as. + */ + fun of(parent: SubgroupParentage): SubgroupLineage = SubgroupLineage(listOf(parent)) + } +} 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 42794c40..319e3782 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -70,14 +70,16 @@ interface ChatRepository { suspend fun subgroupsOf(chatRoomId: String): List /** - * The group this room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified this room as its child, verified, in + * canonical order -- empty if it is nobody's child. * - * Off the room's `GroupKeyState`, which only carries a parent whose - * certificate was checked. A member welcomed in after the founding holds no - * state and gets null here even when the room really is a subgroup. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked. More than one is ordinary: a group is founded with one parent and + * may be adopted by another later. A member welcomed in after the founding + * holds no state and gets an empty list even when the room really is a + * subgroup. */ - suspend fun parentOf(chatRoomId: String): HexKey? + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -114,6 +116,49 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? + /** + * Groups that have offered to adopt this room and are not its parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption needs. + */ + suspend fun adoptionOffersFor(chatRoomId: String): List + + /** The groups this profile could offer [chatRoomId] to, for the picker. */ + suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List + + /** Why [adoptingChatRoomId] cannot adopt [chatRoomId], or null when it can. */ + suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child. + * + * The offer, and only that: the child's own quorum answers it with + * [acceptParent], and nothing is adopted until it does. + */ + suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean + + /** + * Asks this room's quorum to accept [parentChatRoomId], by re-announcing its + * key state with that parentage added to whatever it already claims. + */ + suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List /** @@ -203,7 +248,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, ): MarmotGroupCreation.Outcome /** @@ -279,7 +323,7 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun parentsOf(chatRoomId: String): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, @@ -306,6 +350,31 @@ interface ChatRepository { return null } + override suspend fun adoptionOffersFor(chatRoomId: String): List = emptyList() + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = emptyList() + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = "No database" + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = false + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = false + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { TODO("Not yet implemented") } @@ -386,7 +455,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.Outcome.Failed("No database") 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 cb4da4eb..c61a9e85 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -3,7 +3,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.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -65,6 +65,19 @@ interface DkgRepository { suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * The id of the room a ceremony's key derives, or null while it has no key. + * + * The one name a ceremony and the room it makes have in common, and the only + * way to tell two of a parent's subgroups apart in anything written about + * the room rather than about the ceremony -- a birth certificate names the + * child, not the ceremony that produced it. + * + * Null is the ordinary answer rather than a failure: a ceremony has no key + * until it finishes, and every caller here is watching from before that. + */ + suspend fun roomDerivedBy(dkgSessionId: String): String? + /** * Opens a ritual in [localChatRoom], making this device the coordinator. * @@ -149,10 +162,10 @@ interface DkgRepository { * a finished subgroup with its key state signed, its certificate signed, and * no way to create the room -- the lookup found no ceremony at all. */ - fun observeSignedGroupKeyState(dkgSessionId: String): Flow + fun observeSignedGroupKeyState(dkgSessionId: String): Flow /** Files the state the group signed for a room that now exists. */ - suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? + suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? /** * Asks the parent's quorum to certify the subgroup this ceremony produced. @@ -213,6 +226,8 @@ interface DkgRepository { override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null + override suspend fun roomDerivedBy(dkgSessionId: String): String? = null + override suspend fun proposeRitual( localChatRoom: LocalChatRoom, userPublicKey: HexKey, @@ -242,10 +257,10 @@ interface DkgRepository { chatRoomId: String ): Flow> = flowOf(emptyList()) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = flowOf(null) - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null override suspend fun proposeBirthCertificate( parentChatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 0513166b..136ab20c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -210,13 +210,45 @@ object ProposedEvent { // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: // the key is the room's own id, which the member is already looking at. - GroupKeyStateEvent.KIND -> Summary( - label = "This group's shared key", - detail = listOfNotNull( + // + // ### A state naming parents is a different question, and must read like one + // + // A key state used to be proposed once, at founding, by the coordinator + // who chose its lineage -- so the path and a ceremony id were the whole of + // what a signer needed. A state can be **re-announced** now, and that is + // how a group is adopted: its own quorum accepts a parent by signing a + // state that names one. A member asked to approve that is being asked + // whether this group should answer to another, and showing them a + // derivation path answers a question nobody asked. + // + // So the parents lead when there are any. The proposal is their only + // chance to check -- after it a quorum has spoken -- and the certificates + // are right there in the tags the signature covers, so this costs a + // reader nothing it does not already hold. + // + // What this cannot say is which parents are being *dropped*, because a + // summary is pure and a disavowal is a difference against the row this + // device holds. That belongs to the screen, which has the room. + GroupKeyStateEvent.KIND -> { + val parents = GroupKeyStateEvent.parseLineage(event.tags)?.parentChatRoomIds + val shared = listOfNotNull( GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) ).joinToString(" · ") - ) + + if (parents.isNullOrEmpty()) { + Summary(label = "This group's shared key", detail = shared) + } else { + Summary( + label = if (parents.size == 1) { + "This group's shared key, and the group it answers to" + } else { + "This group's shared key, and the ${parents.size} groups it answers to" + }, + detail = (parents.map { it.take(16) } + shared).joinToString(" · ") + ) + } + } // The other thing a group signs that is about groups rather than about // work, and the only one where the *proposal* is the last chance to 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 f04cd668..d5af2cba 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 @@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.extensions.toFormattedTimeAndDateString @@ -90,12 +91,18 @@ import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.parent_group +import mantra.composeapp.generated.resources.accept_as_a_parent +import mantra.composeapp.generated.resources.add_a_parent_group +import mantra.composeapp.generated.resources.a_group_is_a_subgroup_once_its_own_quorum_says_so +import mantra.composeapp.generated.resources.asked_this_group_to_accept_that_parent +import mantra.composeapp.generated.resources.s_has_offered_to_adopt_this_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member import mantra.composeapp.generated.resources.certified_not_yet_created import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group import mantra.composeapp.generated.resources.add_subgroup import mantra.composeapp.generated.resources.subgroups +import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute @@ -119,6 +126,7 @@ import mantra.composeapp.generated.resources.no_projects_exists_in_this_group import mantra.composeapp.generated.resources.projects import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.reindex_events +import mantra.composeapp.generated.resources.key_ceremonies import mantra.composeapp.generated.resources.shared_key import mantra.composeapp.generated.resources.signing_key import mantra.composeapp.generated.resources.the_key_this_room_signs_as @@ -168,6 +176,13 @@ fun ChatRoomDetailScreen( ) ) + // Both read here because both are composable and an onClick lambda is not. + // The notifier takes the caller's scope on purpose: an acceptance is shown + // while the screen reloads around it, and a message launched in a departing + // composable's scope would be cancelled with it. + val notify = rememberNotifier(rememberCoroutineScope()) + val acceptedParent = stringResource(Res.string.asked_this_group_to_accept_that_parent) + ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState -> when (val chatRoomDetailUIState = uiState) { is ChatRoomDetailUIState.Error -> { @@ -261,8 +276,14 @@ fun ChatRoomDetailScreen( // nobody's child, and absent -- rather than guessed -- // on a subgroup this device holds no key state for, // since an unverified lineage is not one worth showing. - chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId -> - item { + // + // One card per parent, because a room can have more + // than one: founded with one and adopted by another + // later. They are a set rather than a path -- neither + // is "the" parent and the order says nothing about + // which came first -- so each gets an equal row. + chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> + item(key = "parent-$parentChatRoomId") { Card( modifier = Modifier.fillMaxWidth(), onClick = { @@ -306,6 +327,99 @@ fun ChatRoomDetailScreen( } } + // An offer, and it has to read like one. The other + // group's quorum has signed; this group has not, and + // until it does the lineage is unchanged. Rendered as + // a fact about the lineage it would be the consent gap + // reopened from the other end. + chatRoomDetailUIState.adoptionOffers.forEach { offeringChatRoomId -> + item(key = "offer-$offeringChatRoomId") { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = stringResource( + Res.string.s_has_offered_to_adopt_this_group, + offeringChatRoomId.take(16) + ) + ) + }, + supportingContent = { + Text( + text = stringResource( + Res.string + .a_group_is_a_subgroup_once_its_own_quorum_says_so + ), + style = MaterialTheme.typography.bodySmall + ) + }, + trailingContent = { + // Offered to whoever opens the + // screen and can act, not only to + // the member who asked -- every + // step after the ceremony is open + // to any admin holding a share. + if (chatRoomDetailUIState.canAddParent) { + TextButton( + onClick = { + chatRoomDetailViewModel.acceptParent( + offeringChatRoomId + ) { message -> + notify(message ?: acceptedParent) + } + } + ) { + Text( + stringResource( + Res.string.accept_as_a_parent + ) + ) + } + } + } + ) + } + } + } + + // Hidden rather than disabled, the way the subgroup + // entry is: a room with no key state has neither half + // of an adoption to work with, and a device with no + // share cannot sign either of them. + if (chatRoomDetailUIState.canAddParent) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectAdoptingGroupRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier + .width(MaterialTheme.spacing.relatedGap) + ) + + Text(stringResource(Res.string.add_a_parent_group)) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( @@ -671,6 +785,39 @@ fun ChatRoomDetailScreen( } } + // Every room, including the Marmot ones the `mlsGroupState` + // check above keeps "Shared key" out of. That button offers + // a room a key of its own, which only a NIP-17 room can + // want; this one lists the ceremonies a room *holds*, and a + // parent's Marmot room holds one per subgroup the group has + // ever made. Until this there was no way to reach the second + // of them except by scrolling the transcript to its request + // line, and no way at all to reach one whose line had never + // arrived. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + DkgRitualListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Key ceremonies" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.key_ceremonies)) + } + } + // Every room, not only the one that holds the key. Most // of what a group signs it signs in its Marmot room -- // its subgroups' certificates and key states included -- 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 e678a623..e5b842ae 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 @@ -49,6 +49,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -176,12 +177,37 @@ fun ChatRoomMessagingScreen( ChatTranscript( viewModel = chatMessageListViewModel, onOpenSharedKey = { dkgSessionId -> + // The same two reasons the signing lines open + // the queue instead of one proposal. A line + // written before chat rows named their ceremony + // cannot say which it is about, and the room may + // hold several -- the list is the honest answer + // there, showing all of them with their own + // state rather than guessing at the newest, + // which since subgroups moved into the parent's + // room is routinely the finished one. + // + // The other is that the ceremony they tapped is + // not the only one waiting on them, in which + // case it is not the whole of what is being + // asked and a screen showing only it would say + // it was. onNavigateToRoute.invoke( - DkgRitualRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - dkgSessionId = dkgSessionId - ) + if (dkgSessionId == null || + chatMessageListViewModel + .hidesOtherCeremonies(dkgSessionId) + ) { + DkgRitualListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } else { + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId + ) + } ) }, onOpenSigning = { sessionId -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index cc358aa5..d7513e87 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -100,7 +101,7 @@ internal fun DkgApprovalScaffold( ) } ) { padding -> - when (val state = dkgRitualViewModel.dkgRitualUIState) { + when (val state = dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { is DkgRitualUIState.Loading -> Column( modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt new file mode 100644 index 00000000..aa7121b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt @@ -0,0 +1,504 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +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.padding +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.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_subgroup_named_s +import mantra.composeapp.generated.resources.a_subgroups_key +import mantra.composeapp.generated.resources.abandoned_no_key_was_created +import mantra.composeapp.generated.resources.check_the_combined_result +import mantra.composeapp.generated.resources.everything_else +import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key +import mantra.composeapp.generated.resources.key_ceremonies +import mantra.composeapp.generated.resources.open +import mantra.composeapp.generated.resources.review +import mantra.composeapp.generated.resources.s_of_s_members_will_sign_with_it +import mantra.composeapp.generated.resources.send_your_contribution_to_the_key +import mantra.composeapp.generated.resources.set_up_a_shared_key +import mantra.composeapp.generated.resources.the_group_has_a_key_from_this_ceremony +import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up +import mantra.composeapp.generated.resources.the_groups_shared_key +import mantra.composeapp.generated.resources.this_group_has_held_no_key_ceremony +import mantra.composeapp.generated.resources.waiting_for_everyone_to_commit_their_part +import mantra.composeapp.generated.resources.waiting_for_everyone_to_confirm_the_result +import mantra.composeapp.generated.resources.waiting_for_members_to_join +import mantra.composeapp.generated.resources.waiting_for_you +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.DkgRitualListViewModel +import press.mantra.compose.ui.view.state.DkgRitualListUIState + +/** + * Every key ceremony a room holds. + * + * The transcript shows a ceremony as it happens, in among everything else that + * was said, and the standing notice above the composer says when one is waiting + * on the reader. Neither can say *which*, once there are two -- and since a + * subgroup's ChillDKG moved into its parent's room there are routinely two: the + * group's own, long finished, and whatever subgroup is being made this week. + * + * So: one row per ceremony, each carrying its own state, the finished ones kept + * rather than dropped. The ones still waiting on the reader come first, because + * those are the only ones with anything to do in them; the rest are the record + * of how this group came to hold the keys it holds, which is the other half of + * what this screen is for. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DkgRitualListScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val dkgRitualListViewModel: DkgRitualListViewModel = viewModel( + factory = DkgRitualListViewModel.factory( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + initialDkgRitualListUIState = initialDkgRitualListUIState, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + ) + + LaunchedEffect(true) { + if (initialDkgRitualListUIState == DkgRitualListUIState.Loading) { + dkgRitualListViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.key_ceremonies), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + when (val state = dkgRitualListViewModel.dkgRitualListUIState.collectAsState().value) { + is DkgRitualListUIState.Loading -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).readableContent() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + CircularProgressIndicator() + } + + is DkgRitualListUIState.Error -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).readableContent() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = state.message, textAlign = TextAlign.Center) + } + + is DkgRitualListUIState.Loaded -> { + val open: (DkgRitualListUIState.Ceremony) -> Unit = { ceremony -> + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + dkgSessionId = ceremony.session.id + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (state.ceremonies.isEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth() + .padding(top = MaterialTheme.spacing.space600), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.sectionGap + ) + ) { + Text( + text = stringResource( + Res.string.this_group_has_held_no_key_ceremony + ), + textAlign = TextAlign.Center + ) + + // The way out of an empty list, rather than a + // dead end that sends a member back to the + // group's details to find the other door. The + // ceremony screen decides whether the group is + // in a position to hold one; this only offers + // to ask it. + FilledTonalButton(onClick = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + }) { + Icon(Icons.Default.Key, contentDescription = Decorative) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.relatedGap + ) + ) + + Text(stringResource(Res.string.set_up_a_shared_key)) + } + } + } + } + + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.waiting_for_you), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + items( + items = state.waitingForYou, + key = { it.session.id } + ) { ceremony -> + CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) }) + } + } + + if (state.rest.isNotEmpty()) { + // Headed only when there is a section above to tell it + // apart from; on its own it is the whole screen, which + // the title already says. + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.everything_else), + style = MaterialTheme.typography.labelMedium + ) + } + } + + items( + items = state.rest, + key = { it.session.id } + ) { ceremony -> + CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) }) + } + } + } + } + } + } +} + +/** + * One ceremony, named after the key it is making. + * + * What a ceremony is *for* is the first thing to say about it, because a room + * holding two holds them for different groups: one is this room's own key and + * the other belongs to a subgroup that does not exist yet. A row that only said + * how far along it was would leave a member guessing which of the two they were + * looking at. + */ +@Composable +private fun CeremonyCard( + ceremony: DkgRitualListUIState.Ceremony, + onClick: () -> Unit, +) { + val session = ceremony.session + + // Every colour inside this card is derived from the card rather than reached + // for independently, because ListItem does not inherit LocalContentColor -- + // its headline comes from `onSurface` whatever the container is. See + // ProposalListScreen, where that combination drew #1B1B1B on #1B1B1B. + val cardColors = if (ceremony.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } + val cardContentColor = cardColors.contentColor + + // A subgroup is named by its proposal, and a member who did not open it may + // be looking at the ceremony before that name has arrived -- hence the third + // branch, which still says which of the two kinds of key this is. + val subject = session.subject + + val label = when { + !ceremony.isSubgroup -> stringResource(Res.string.the_groups_shared_key) + subject != null -> stringResource(Res.string.a_subgroup_named_s, subject) + else -> stringResource(Res.string.a_subgroups_key) + } + + Card(onClick = onClick, colors = cardColors) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = cardContentColor, + overlineColor = cardContentColor, + supportingColor = cardContentColor, + leadingIconColor = cardContentColor, + trailingIconColor = cardContentColor, + ), + leadingContent = { + Icon( + imageVector = when { + ceremony.awaitsYou -> Icons.Default.Key + session.stage == DkgRitualStage.COMPLETE -> Icons.Default.CheckCircle + session.stage == DkgRitualStage.FAILED -> Icons.Default.ErrorOutline + else -> Icons.Default.HourglassEmpty + }, + // Carries the stage on its own, and carries it alone on the + // highlighted card where the failure state has no colour to + // spare. + contentDescription = when { + ceremony.awaitsYou -> "Waiting on you" + session.stage == DkgRitualStage.COMPLETE -> "Finished" + session.stage == DkgRitualStage.FAILED -> "Abandoned" + else -> "Waiting on others" + }, + tint = when { + // `error` is 2.67:1 on the highlighted card, so there the + // failure is carried by the icon's shape and by the status + // line rather than by colour. + session.stage == DkgRitualStage.FAILED && + !ceremony.awaitsYou -> MaterialTheme.colorScheme.error + else -> cardContentColor + } + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + // The same word the transcript's notice uses for the same + // thing, so a member who came here looking for the card they + // tapped finds it saying what it said there. + Text( + text = if (ceremony.awaitsYou) { + stringResource(Res.string.review) + } else { + stringResource(Res.string.open) + }, + style = MaterialTheme.typography.labelLarge, + color = cardContentColor + ) + + Icon( + Icons.Default.ChevronRight, + contentDescription = "Open this ceremony" + ) + } + }, + overlineContent = { Text(text = label) }, + headlineContent = { + Text( + text = statusOf(ceremony), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25) + ) { + // The quorum a ceremony settles is fixed by it and cannot be + // changed afterwards, so it belongs on the row rather than + // only on the screen behind it. + Text( + text = stringResource( + Res.string.s_of_s_members_will_sign_with_it, + session.threshold, + session.participantCount + ) + ) + + Text( + text = "${ceremony.coordinatorName} · " + + session.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** + * Where a ceremony has got to, in a line. + * + * Shorter than the ceremony screen's ladder, and it has to be: this is one row + * of a list somebody is scanning, not the page they are acting on. What it must + * never do is call a ceremony finished when it is not, or waiting when there is + * nothing left to wait for. + * + * What it owes this member outranks the stage, in the words the approval screens + * use for the same step, so nobody is asked twice in two vocabularies. + */ +@Composable +private fun statusOf(ceremony: DkgRitualListUIState.Ceremony): String { + ceremony.pendingApproval?.let { + return when (it) { + DkgApprovalStep.HOST_KEY -> + stringResource(Res.string.join_the_ceremony_by_publishing_your_key) + DkgApprovalStep.ROUND_1 -> + stringResource(Res.string.send_your_contribution_to_the_key) + DkgApprovalStep.ROUND_2 -> + stringResource(Res.string.check_the_combined_result) + } + } + + if (ceremony.roomAwaitingCreation != null) { + return stringResource(Res.string.the_group_has_its_key_finish_setting_it_up) + } + + return when (ceremony.session.stage) { + DkgRitualStage.COLLECTING_HOST_KEYS -> + stringResource(Res.string.waiting_for_members_to_join) + + DkgRitualStage.COLLECTING_ROUND_1 -> + stringResource(Res.string.waiting_for_everyone_to_commit_their_part) + + DkgRitualStage.COLLECTING_ROUND_2 -> + stringResource(Res.string.waiting_for_everyone_to_confirm_the_result) + + DkgRitualStage.COMPLETE -> + stringResource(Res.string.the_group_has_a_key_from_this_ceremony) + + // The reason is the whole content of a failure -- "a member refused" and + // "the group could not agree" are different things to have happened -- so + // it stands in for the bare word whenever there is one. + DkgRitualStage.FAILED -> + ceremony.session.failureReason + ?: stringResource(Res.string.abandoned_no_key_was_created) + } +} + +@ConformancePreviews +@Composable +private fun DkgRitualListScreenPreview() { + val session = DkgSession( + id = "sessionId", + chatRoomId = "chatRoomId", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + threshold = 2, + participantCount = 3, + hostPublicKey = "h".repeat(64), + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DkgRitualListScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = "chatRoomId", + initialDkgRitualListUIState = DkgRitualListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "u".repeat(64), + subject = "#Ekklesia", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ) + ), + ceremonies = listOf( + DkgRitualListUIState.Ceremony( + session = session.copy( + id = "subgroupSessionId", + parentChatRoomId = "parentChatRoomId", + subject = "Translators", + stage = DkgRitualStage.COLLECTING_ROUND_1 + ), + coordinatorName = "Ada", + pendingApproval = DkgApprovalStep.ROUND_1 + ), + DkgRitualListUIState.Ceremony( + session = session.copy(stage = DkgRitualStage.COMPLETE), + coordinatorName = "You" + ) + ) + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index f669285c..3ea26e01 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -154,7 +155,7 @@ fun DkgRitualScreen( ) ) - ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState -> + ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { uiState -> when (val dkgRitualUIState = uiState) { is DkgRitualUIState.Error -> { Column( @@ -168,7 +169,7 @@ fun DkgRitualScreen( is DkgRitualUIState.Loaded -> { val session = dkgRitualUIState.session val participantCount = dkgRitualUIState.participantCount - val canStartRitual = dkgRitualViewModel.canStartRitual() + val canStartRitual = dkgRitualViewModel.canStartRitual(dkgRitualUIState) val isActionPending = dkgRitualViewModel.isActionPending.value Scaffold( @@ -286,7 +287,7 @@ fun DkgRitualScreen( QuorumStepper( threshold = dkgRitualViewModel.threshold.value, participantCount = participantCount, - quorumRange = dkgRitualViewModel.quorumRange(), + quorumRange = dkgRitualViewModel.quorumRange(dkgRitualUIState), onThresholdChange = dkgRitualViewModel::setThreshold ) } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt new file mode 100644 index 00000000..16313ef2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt @@ -0,0 +1,195 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.launch +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one +import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one +import mantra.composeapp.generated.resources.which_group_should_adopt_this_one +import mantra.composeapp.generated.resources.who_adopts_this_group +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SelectAdoptingGroupViewModel +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * Tapping a row asks that group's quorum to certify this room as its child. That + * is an **offer** and nothing more: this room becomes their subgroup only once + * its own quorum re-announces its key state naming them, which is a card back on + * the room's own screen. So this hands back rather than going anywhere new, and + * the snackbar says what was asked rather than what was done. + * + * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for + * why it can be nothing else -- so empty is an ordinary answer and the empty + * state explains it rather than apologising. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectAdoptingGroupScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + onNavigateBack: () -> Unit, +) { + val viewModel: SelectAdoptingGroupViewModel = viewModel( + factory = SelectAdoptingGroupViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + ) + + LaunchedEffect(chatRoomId) { viewModel.initiate() } + + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + // Read outside the handler: both of these are composable and an onClick + // lambda is not. The notifier takes the caller's scope because the message is + // shown as this screen navigates away, and one launched in the departing + // composable's scope would be cancelled with it. + val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one) + + ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { + is SelectAdoptingGroupUIState.Error -> ErrorState( + message = state.message, + // Retrying reloads the group and the candidates, which is what + // failed. + onRetry = { viewModel.initiate() } + ) + + SelectAdoptingGroupUIState.Loading -> LoadingDataIndicator() + + is SelectAdoptingGroupUIState.Loaded -> Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.who_adopts_this_group)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + if (state.candidates.isEmpty()) { + item { + // The message carries the reason, because the + // reason is the whole content of this absence: a + // group cannot be offered as a parent by somebody + // who is not in it, so the remedy is a person. + EmptyState( + message = stringResource( + Res.string.no_group_here_can_adopt_this_one + ) + ) + } + } else { + item { + Text( + text = stringResource( + Res.string.which_group_should_adopt_this_one + ), + style = MaterialTheme.typography.labelMedium + ) + } + + items( + items = state.candidates, + key = { candidate -> candidate.chatRoom.id } + ) { candidate -> + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + viewModel.offerTo(candidate.chatRoom.id) { refusal -> + scope.launch { + snackbarHostState.showSnackbar(refusal ?: asked) + } + if (refusal == null) onNavigateBack() + } + } + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = candidate.chatRoom.subject + ?: candidate.chatRoom.id.take(16), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = candidate.chatRoom.id.take(16), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + } + } + } + } +} 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 64ebcde5..f282dafd 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 @@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen import press.mantra.compose.ui.composable.ChatRoomMessagingScreen import press.mantra.compose.ui.composable.CreateProfileScreen import press.mantra.compose.ui.composable.DkgJoinApprovalScreen +import press.mantra.compose.ui.composable.DkgRitualListScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen @@ -99,7 +100,9 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute +import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.SelectAdoptingGroupScreen import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute @@ -140,6 +143,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute @@ -605,6 +609,18 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectAdoptingGroupScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -1257,6 +1273,22 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgRitualListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { ceremonyRoute -> + navController.navigate(route = ceremonyRoute) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt new file mode 100644 index 00000000..e23efb54 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Every key ceremony a room holds. + * + * The room's list, not the reader's: a ceremony is run by the whole group, and + * which of them are waiting on this member is a status on each row rather than + * a different list -- the same shape as [ProposalListRoute], for the same + * reason. + * + * A room holds more than one as a matter of course since a subgroup's ChillDKG + * moved into its parent's room, and until this there was no screen that could + * name the second: [DkgRitualRoute] without a session id shows the newest, and + * the newest is routinely the group's own, long finished, with the subgroup's + * request buried underneath it. + */ +@Serializable +data class DkgRitualListRoute( + val activeUserPublicKey: String, + val chatRoomId: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt new file mode 100644 index 00000000..5c018ebc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings, and the only one that needs a screen + * of its own: the acceptance is a card on the child's own detail screen, because + * by then there is something to accept. + * + * Carries only the child's id. The candidates are not on the route and must not + * be: they are the rooms the **profile in the chair** can act in, which a route + * cannot know and which changes while the screen is open -- see + * `SubgroupManager.adoptingCandidatesFor`. + */ +@Serializable +data class SelectAdoptingGroupRoute( + val activeUserPublicKey: String, + val chatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index ca9c4bc6..a69c8b8d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -578,7 +578,8 @@ fun ChatTranscript( @Composable private fun CeremoniesAwaitingYouNotice( ceremonies: List, - onClick: (dkgSessionId: String) -> Unit, + /** Null where the notice stands for more than one, which no single screen can show. */ + onClick: (dkgSessionId: String?) -> Unit, ) { val single = ceremonies.singleOrNull() @@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice( } Card( - onClick = { onClick(ceremonies.first().sessionId) }, + // The one it names when it names one, and the list otherwise. A notice + // reading "3 key ceremonies need you" that opened the first of them -- + // which is what this did -- answered a different question from the one + // it asked, and left the other two exactly as hard to find as before. + onClick = { onClick(single?.sessionId) }, modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -812,7 +817,8 @@ private fun RitualNotice( // An invite made and an invite sent are two separate steps on the deferred // path, so they get separate icons -- the whole reason both lines exist is // to be able to see that the first happened and the second did not. - ChatMessage.TYPE_SUBGROUP_CERTIFIED -> Icons.Default.AccountTree + ChatMessage.TYPE_SUBGROUP_CERTIFIED, + ChatMessage.TYPE_SUBGROUP_ADOPTION_OFFERED -> Icons.Default.AccountTree ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline 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 78fd1923..37fe4e9e 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 @@ -156,6 +156,25 @@ class ChatMessageListViewModel( proposalsAwaitingYou.size > 1 && proposalsAwaitingYou.any { it.sessionId == sessionId } + /** + * Whether opening [sessionId] by itself would hide other ceremonies the room + * is waiting on this member for. + * + * [hidesOtherDecisions]'s counterpart, and the same argument: a ceremony line + * names one ceremony, and opening it is right while that is the only one + * asking. A room holds more than one as a matter of course since a subgroup's + * ChillDKG moved into its parent's room, and the second is not in the + * transcript beside the first -- so answering the one they tapped and leaving + * would look exactly like being done. + * + * Only for a ceremony that is itself waiting on them. A line about a ceremony + * the group finished is history, and a reader who taps one is asking to see + * that, not to be handed the queue. + */ + fun hidesOtherCeremonies(sessionId: String): Boolean = + ceremoniesAwaitingYou.size > 1 && + ceremoniesAwaitingYou.any { it.sessionId == sessionId } + fun startDirectMessage(participant: Participant) { logger.d("Arming a direct message to ${participant.participantPublicKey}") directMessageRecipient = participant 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 260d5da1..16fc5589 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 @@ -82,14 +82,55 @@ class ChatRoomDetailViewModel( groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomId = chatRepository.parentOf(chatRoomId), + parentChatRoomIds = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), + adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), + // A key state as well as a share: the certificate carries + // this group's threshold key, and the acceptance re-announces + // that very state, so a room that has agreed nothing has + // neither half to work with. + canAddParent = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.groupKeyState(chatRoomId) != null && + chatRepository.canSign(chatRoomId), ) } } } + /** + * Asks this group's quorum to accept [parentChatRoomId] as a parent. + * + * The acceptance, and the half that decides: the certificate is an offer + * until a quorum of *this* group has re-announced its key state naming them. + * `SubgroupManager.proposeAcceptance` carries the whole resulting set, so a + * group that already has a parent keeps it. + * + * [onDone] is called with null on success and a message otherwise, so the + * caller can show it from a scope that outlives this screen. + */ + fun acceptParent(parentChatRoomId: HexKey, onDone: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val accepted = chatRepository.acceptParent( + chatRoomId = chatRoomId, + parentChatRoomId = parentChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (!accepted) { + logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId") + onDone("Couldn't ask this group to accept that parent. Please try again.") + return@launch + } + + // The offer leaves the list only when the state is signed and filed, + // which is a quorum away -- so this reloads rather than crossing it + // off, and the card stays until the group has actually agreed. + initiateChatRoomDetail() + onDone(null) + } + } + fun leaveGroup( localChatRoom: LocalChatRoom, onPopBackToRoute: (Route) -> Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt new file mode 100644 index 00000000..38677d1c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt @@ -0,0 +1,140 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.memberName +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualListUIState + +/** + * Every key ceremony a room holds. + * + * A room routinely holds more than one: a parent's Marmot room hosts the + * ceremony of every subgroup the group ever makes, alongside whatever it holds + * of its own. Watching the whole list rather than the newest is the point -- + * which of them is waiting on the reader is a fact about each one, and the + * newest is routinely the finished one. + * + * The counterpart to [ProposalListViewModel], and deliberately the same shape. + */ +class DkgRitualListViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + initialDkgRitualListUIState: DkgRitualListUIState, + val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, +): ViewModel() { + + private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState) + + /** + * A flow rather than a `mutableStateOf`: this is written from + * `Dispatchers.IO`, and a `mutableStateOf` created in a view model's + * constructor silently drops a write that lands during the composition that + * created it. See [DkgRitualViewModel.dkgRitualUIState]. + */ + val dkgRitualListUIState: StateFlow = + _dkgRitualListUIState.asStateFlow() + + private val logger = Logger.withTag(TAG) + + /** + * Loads the room, then watches its ceremonies for as long as the screen + * lives. + * + * Every one of them moves on messages arriving from other members, so a + * screen that read the list once would go stale the moment somebody else + * took their step -- which is exactly when a member is likely to be looking + * at it. + */ + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + _dkgRitualListUIState.value = + DkgRitualListUIState.Error("Couldn't find the group") + return@launch + } + + dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions -> + logger.d("${sessions.size} key ceremonies in $chatRoomId") + + _dkgRitualListUIState.value = DkgRitualListUIState.Loaded( + localChatRoom = localChatRoom, + ceremonies = sessions.map { asCeremony(it, localChatRoom) } + ) + } + } + } + + /** + * One ceremony as a row. + * + * What it wants is asked of the repository rather than worked out here, so + * this list, the transcript's standing notice and the ceremony screen cannot + * disagree about which ceremonies still have something in them -- the same + * rule the proposal list keeps. + * + * An abandoned ceremony is asked nothing. It can want nothing: there is no + * step left to approve and no key to build a room from, and a row claiming + * otherwise would send a member to a screen with nothing on it. + */ + private suspend fun asCeremony( + session: DkgSession, + localChatRoom: LocalChatRoom + ): DkgRitualListUIState.Ceremony { + val live = session.takeIf { it.stage != DkgRitualStage.FAILED } + + return DkgRitualListUIState.Ceremony( + session = session, + coordinatorName = session.coordinatorPublicKey.memberName( + members = localChatRoom.localParticipants, + activeUserPublicKey = activeUserPublicKey + ), + pendingApproval = live?.let { dkgRepository.pendingApproval(it) }, + // Only of the member who opened it: creating the room is the + // coordinator's job, and a row telling everybody else to do it would + // be telling them to do something they cannot. + roomAwaitingCreation = live + ?.takeIf { it.isCoordinator() } + ?.let { dkgRepository.roomAwaitingCreation(it) } + ) + } + + companion object { + private const val TAG = "DkgRitualListViewModel" + + fun factory( + chatRoomId: String, + activeUserPublicKey: HexKey, + initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + DkgRitualListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + initialDkgRitualListUIState = initialDkgRitualListUIState, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + } + } + } +} 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 d369fd14..5f6858ac 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 @@ -1,9 +1,7 @@ package press.mantra.compose.ui.view.model import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -36,7 +34,10 @@ import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -60,8 +61,51 @@ class DkgRitualViewModel( val dkgRepository: DkgRepository, ): ViewModel() { - var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState) - private set + private val _dkgRitualUIState = MutableStateFlow(initialDkgRitualUIState) + + /** + * What the screen draws -- a flow rather than a `mutableStateOf`, because + * seven flows write it. + * + * The ceremony, the room's other ceremonies, the ceremony's messages, the + * key state, whether the key state is signed, the certificate and the + * certificate's signing all fold into this one object, and every one of + * them emits on Room's own executor. Held + * as a `var ... by mutableStateOf`, each of those was a read, a `copy` and + * an assignment with a suspension point in the middle, so two emissions + * arriving together silently dropped one: whichever read first wrote last, + * over the top of the other's change. + * + * The change that went missing most visibly was [DkgRitualUIState.Loaded.session], + * copied back to null by a watcher that had read the state before the + * ceremony landed in it -- and a screen with no session is the screen that + * offers to start one. It stayed that way until some other table changed and + * the session flow emitted again, which for a ceremony waiting on an absent + * member is never. + * + * [MutableStateFlow.update] retries on a losing compare-and-set, so a change + * can only be folded into the state that is actually current. Every write + * below goes through it -- see [updateLoaded]. It is the same rule the + * wallet view models already keep, for the same reason. + */ + val dkgRitualUIState: StateFlow = _dkgRitualUIState.asStateFlow() + + /** + * Folds a change into the loaded state, atomically, and does nothing when + * the screen is not in it -- the `as? Loaded ?: return` that every one of + * these call sites used to write by hand. + * + * [block] is re-run when it loses the race, so it has to be a pure function + * of the state it is handed: read nothing else out of the view model inside + * it, and do the suspending work above the call. + */ + private inline fun updateLoaded( + block: (DkgRitualUIState.Loaded) -> DkgRitualUIState.Loaded + ) { + _dkgRitualUIState.update { current -> + if (current is DkgRitualUIState.Loaded) block(current) else current + } + } private val logger = Logger.withTag(TAG) @@ -70,9 +114,6 @@ class DkgRitualViewModel( /** Watches the current session's messages; replaced whenever the session changes. */ private var messageObserver: Job? = null - /** Watches the room's signing sessions, which is where the key state is agreed. */ - private var keyStateObserver: Job? = null - /** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */ private var certificateObserver: Job? = null @@ -101,9 +142,15 @@ class DkgRitualViewModel( * Any member may: ChillDKG gives the coordinator no say in the outcome, and a * NIP-17 room records no creator to reserve the job for. What rules it out is * a ceremony already running, or a group too small to split a key across. + * + * [state] is passed in by the screen rather than read off the view model, + * because the state is a flow now and reading `.value` inside composition + * observes nothing -- the offer would be decided once and never revised. The + * screen hands in the state it is drawing, which is also the only state the + * answer should be about while a transition still holds the outgoing one. */ - fun canStartRitual(): Boolean { - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false + fun canStartRitual(state: DkgRitualUIState = dkgRitualUIState.value): Boolean { + val loaded = state as? DkgRitualUIState.Loaded ?: return false if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false @@ -116,8 +163,8 @@ class DkgRitualViewModel( return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom) } - fun quorumRange(): IntRange { - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded + fun quorumRange(state: DkgRitualUIState = dkgRitualUIState.value): IntRange { + val loaded = state as? DkgRitualUIState.Loaded ?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM) // Below the minimum the range runs backwards and is empty, which `coerceIn` @@ -137,17 +184,10 @@ class DkgRitualViewModel( viewModelScope.launch(Dispatchers.IO) { val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) if (localChatRoom == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't load this chat") return@launch } - dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom) - threshold.value = ChatRoomType - .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) - .coerceIn(quorumRange()) - - observeKeyState() - // Room-scoped, deliberately. Only the member who opened a subgroup // arrives here knowing it is one -- everybody else reaches this screen // from the room, through the transcript's ritual notice or the group's @@ -171,30 +211,63 @@ class DkgRitualViewModel( // Whether the room has any ceremony at all, which is a different // question from which one is being shown -- see // `DkgRitualUIState.roomHasCeremony`. - launch { - dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { newest -> - val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + val roomSessions = dkgRepository.observeLatestSessionForChatRoom(chatRoomId) - dkgRitualUIState = current.copy( - roomHasCeremony = newest != null && newest.stage != DkgRitualStage.FAILED - ) + // Both answers are read here, before anything is published, rather + // than awaited after a bare `Loaded` has gone up. A `Loaded` with no + // session and no ceremony *is* the screen that offers to start one, + // so publishing it first flashed "start key ceremony" over a ceremony + // already running -- for as long as the two queries took, which is + // long enough to read and long enough to tap. The flows below keep + // both current from here on; this only settles what the first frame + // says. + val shownSession = sessions.first() + val newestSession = roomSessions.first() + + _dkgRitualUIState.value = DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + session = shownSession, + parentChatRoomId = shownSession?.parentChatRoomId, + pendingApproval = shownSession?.let { dkgRepository.pendingApproval(it) }, + roomHasCeremony = newestSession != null && + newestSession.stage != DkgRitualStage.FAILED + ) + + threshold.value = ChatRoomType + .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) + .coerceIn(quorumRange()) + + launch { + roomSessions.collect { newest -> + updateLoaded { current -> + current.copy( + roomHasCeremony = newest != null && + newest.stage != DkgRitualStage.FAILED + ) + } } } sessions.collect { session -> - val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) - // The session is the only thing that knows what the ceremony is // for. The route used to carry it too, which was right for the one // member who opened the subgroup and null for everybody else. val parent = session?.parentChatRoomId - dkgRitualUIState = loaded.copy( - session = session, - parentChatRoomId = parent, - pendingApproval = session?.let { dkgRepository.pendingApproval(it) } - ) + // Above the update, not inside it: `update` re-runs its block when + // it loses the race, and a block that re-runs cannot suspend. + val pending = session?.let { dkgRepository.pendingApproval(it) } + + _dkgRitualUIState.update { current -> + val loaded = current as? DkgRitualUIState.Loaded + ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + + loaded.copy( + session = session, + parentChatRoomId = parent, + pendingApproval = pending + ) + } // Re-point the per-ceremony watchers whenever the ceremony or its // purpose changes, which for a member arriving cold is once. @@ -212,23 +285,32 @@ class DkgRitualViewModel( if (session != null) { messageObserver = viewModelScope.launch(Dispatchers.IO) { dkgRepository.observeMessages(session.id).collect { messages -> - val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - fun senders(kind: Kind) = messages .filter { it.kind == kind } .map { it.participantPublicKey } .toSet() - dkgRitualUIState = current.copy( - hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY), - round1Participants = senders(DkgRitualEvents.ROUND_1), - round2Participants = senders(DkgRitualEvents.ROUND_2), - // Recomputed here as well as on the session, because - // whether round 1 can be asked for depends on how many - // host keys have arrived, which only this flow sees. - pendingApproval = current.session - ?.let { dkgRepository.pendingApproval(it) } - ) + // Recomputed here as well as on the session, because + // whether round 1 can be asked for depends on how many + // host keys have arrived, which only this flow sees. + val pending = dkgRepository.pendingApproval(session) + + updateLoaded { current -> + // This watcher belongs to one ceremony, and + // cancelling it does not reach into a body already + // running -- so say which ceremony these counts are + // for rather than letting them land on the next. + if (current.session?.id != session.id) { + return@updateLoaded current + } + + current.copy( + hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY), + round1Participants = senders(DkgRitualEvents.ROUND_1), + round2Participants = senders(DkgRitualEvents.ROUND_2), + pendingApproval = pending + ) + } } } } @@ -236,45 +318,6 @@ class DkgRitualViewModel( } } - /** - * Follows the group agreeing what its admin room will sign with. - * - * Nothing here watches a [GroupKeyState] row, because there is no room yet - * for one to belong to -- being able to follow this before the room exists - * is the point. What is watched instead is the session, for how the signing - * is going, and the signed event, for whether it finished. - * - * The room a ceremony runs in signs nothing else, so the newest session in - * it is the key state's -- but it is matched on the kind it carries anyway, - * so a room that one day signs something else does not confuse the two. - */ - private fun observeKeyState() { - keyStateObserver?.cancel() - keyStateObserver = viewModelScope.launch(Dispatchers.IO) { - // Two flows because they answer two questions off two tables. How - // the signing is going is the session row; whether it finished is - // the signed event, and only that second one can be asked at all - // before there is a room -- which is the whole point of asking it. - launch { - dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - - dkgRitualUIState = loaded.copy( - keyStateSession = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == GroupKeyStateEvent.KIND - } - }?.session - ) - } - } - - - } - } - /** * Follows one ceremony: whether its key state has been signed, and -- for a * subgroup -- whether its parent has certified it. @@ -293,54 +336,113 @@ class DkgRitualViewModel( certificateObserver?.cancel() if (dkgSessionId == null) { - dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) - ?: dkgRitualUIState + updateLoaded { + it.copy( + isKeyStateSigned = false, + keyStateSession = null, + isCertified = false, + certificateSession = null + ) + } return } certificateObserver = viewModelScope.launch(Dispatchers.IO) { + // How the agreement is going, as opposed to whether it finished -- + // two questions off two tables, and only the second can be asked + // before there is a room at all, which is the point of asking it. + // + // By the ceremony the proposal names, not by kind in this room. The + // room a ceremony runs in used to sign nothing else, and that is + // what this read assumed; a parent's Marmot room now hosts the + // ceremony of every subgroup the group makes, so it holds one of + // these per subgroup. Matching on kind alone handed the screen the + // newest of them -- which is some other subgroup's -- and with it a + // progress bar for a signing this ceremony is not in and a button + // disabled on the strength of it. Permanently, once that other one + // completed: the guard only reopens on a failure. + // + // `FrostKeyTag` is on the proposal from the moment it is made, so + // this works before the ceremony has a key and before anybody signs. + launch { + dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> + val keyState = sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + event.kind == GroupKeyStateEvent.KIND && + GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId + } + }?.session + + updateLoaded { it.copy(keyStateSession = keyState) } + } + } + // Whether the group has agreed what the room this ceremony's key // derives will sign with. Asked by ceremony rather than by room: the // room holds however many ceremonies it holds, and the answer is // about this one's key. launch { dkgRepository.observeSignedGroupKeyState(dkgSessionId).collect { state -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - - dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null) + updateLoaded { it.copy(isKeyStateSigned = state != null) } } } if (parentChatRoomId == null) { - dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?.copy(isCertified = false, certificateSession = null) - ?: dkgRitualUIState + updateLoaded { it.copy(isCertified = false, certificateSession = null) } return@launch } launch { dkgRepository.observeBirthCertificate(dkgSessionId, parentChatRoomId) .collect { certificate -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - - dkgRitualUIState = loaded.copy(isCertified = certificate != null) + updateLoaded { it.copy(isCertified = certificate != null) } } } launch { + // The child this ceremony is making, which is what a certificate + // names. Resolved per emission rather than once, because the + // ceremony has no key until it finishes and this flow is running + // before it does -- the same reason `observeBirthCertificate` + // resolves it per emission. + // + // Scoped to that child, not to the kind. A parent certifies every + // subgroup it ever makes from the same room, so by kind alone this + // found the newest certificate signing in the parent -- some other + // child's -- and read its progress as this one's. Worse, the rung + // disables "ask the group to certify" while a certificate session + // is running and un-failed, so once a parent had certified one + // subgroup it could never be asked to certify another. dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId) - dkgRitualUIState = loaded.copy( - certificateSession = sessions.firstOrNull { local -> + val certificate = subgroupChatRoomId?.let { child -> + sessions.firstOrNull { local -> local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == SubgroupBirthCertificateEvent.KIND + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + // `addresses`, not `certifies`: the thing being + // matched is a proposal, and `certifies` ends on + // a signature by the parent's quorum, which is + // precisely what has not happened yet. Whether + // the parent actually agreed is `isCertified`, + // which does check it. + SubgroupBirthCertificateEvent.addresses( + event = event, + subgroupChatRoomId = child, + parentChatRoomId = parentChatRoomId, + ) } }?.session - ) + } + + updateLoaded { it.copy(certificateSession = certificate) } } } } @@ -359,7 +461,7 @@ class DkgRitualViewModel( fun proposeKeyState() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return if (session.thresholdPublicKey == null) return @@ -398,7 +500,7 @@ class DkgRitualViewModel( if (signing == null) { logger.e("Failed to propose the key state for $chatRoomId") - dkgRitualUIState = DkgRitualUIState.Error( + _dkgRitualUIState.value = DkgRitualUIState.Error( "Couldn't ask the group to agree its signing key. Please try again." ) } @@ -421,7 +523,7 @@ class DkgRitualViewModel( fun proposeBirthCertificate() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return // The state's, not the route's. Every step after the ceremony is open to // any of the subgroup's admins -- see `SubgroupManager` -- and they reach // this screen from the room, with no route to have told them anything. @@ -459,7 +561,7 @@ class DkgRitualViewModel( if (signing == null) { logger.e("Failed to propose a birth certificate in $parent") - dkgRitualUIState = DkgRitualUIState.Error( + _dkgRitualUIState.value = DkgRitualUIState.Error( "Couldn't ask the group to certify this subgroup. Please try again." ) } @@ -470,11 +572,11 @@ class DkgRitualViewModel( fun startRitual() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -492,7 +594,7 @@ class DkgRitualViewModel( if (session == null) { logger.e("Failed to start DKG ritual for $chatRoomId") - dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.") } // On success the session flow above delivers the new row; no need to // set it here and risk racing the observer. @@ -509,12 +611,12 @@ class DkgRitualViewModel( fun approve(step: DkgApprovalStep) { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -558,7 +660,7 @@ class DkgRitualViewModel( fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return val thresholdPublicKey = session.thresholdPublicKey ?: return @@ -568,7 +670,7 @@ class DkgRitualViewModel( val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -594,7 +696,7 @@ class DkgRitualViewModel( ?: if (subgroupParent != null) "Subgroup" else "Group" isActionPending.value = true - dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList()) + updateLoaded { it.copy(adminGroupBlockedOn = emptyList()) } viewModelScope.launch(Dispatchers.IO) { val outcome = chatRepository.createMarmotGroup( @@ -607,11 +709,7 @@ class DkgRitualViewModel( }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, - keyPair = keyPair, - // Verified by the time it is written: the key state this room was - // gated on carries the certificate, and `stateFrom` refused it - // otherwise. - parentChatRoomId = subgroupParent + keyPair = keyPair ) isActionPending.value = false @@ -621,19 +719,21 @@ class DkgRitualViewModel( // Named rather than counted. A member cannot publish a key // package on somebody else's behalf, so the only useful thing // to say is whose door to knock on. - dkgRitualUIState = loaded.copy( - adminGroupBlockedOn = outcome.missing.map { publicKey -> - loaded.ritualMembers - .firstOrNull { it.participant.participantPublicKey == publicKey } - ?.profile - ?.humanReadableNameOrPubkey() - ?: publicKey.take(12) - } - ) + updateLoaded { current -> + current.copy( + adminGroupBlockedOn = outcome.missing.map { publicKey -> + current.ritualMembers + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.take(12) + } + ) + } } is MarmotGroupCreation.Outcome.Failed -> { - dkgRitualUIState = DkgRitualUIState.Error(outcome.reason) + _dkgRitualUIState.value = DkgRitualUIState.Error(outcome.reason) } is MarmotGroupCreation.Outcome.Created -> withContext(Main) { @@ -661,7 +761,6 @@ class DkgRitualViewModel( override fun onCleared() { messageObserver?.cancel() - keyStateObserver?.cancel() certificateObserver?.cancel() super.onCleared() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt new file mode 100644 index 00000000..a46ef417 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings. Confirming proposes a birth + * certificate in the **chosen** group's room, and that is all it does: the + * certificate is an **offer**, and this room is not that group's subgroup until + * its own quorum re-announces its key state naming them. So this screen hands + * back to the room it came from rather than anywhere new -- the acceptance is a + * card there, and the answer belongs to whoever opens it. + * + * ### The candidates cannot be anything but this profile's own rooms + * + * A certificate is proposed *inside* the adopting group, so a candidate has to be + * a room the key in the chair can act in: a Marmot room this profile administers + * and holds a share of, which is not this room, is not already a parent, has not + * already offered, and is not already beneath this one. Every one of those is + * `SubgroupManager.refuseAdoption`'s, run through that function rather than + * through a copy of its rules, so the screen and the manager cannot drift. + * + * An empty list is therefore a fact about this member rather than a failure, and + * the screen says so: a group cannot be offered as a parent by somebody who is + * not in it, and the remedy is a person rather than a button. + */ +class SelectAdoptingGroupViewModel( + val activeUserPublicKey: HexKey, + val chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var uiState: SelectAdoptingGroupUIState by mutableStateOf(initialUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val childRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (childRoom == null) { + uiState = SelectAdoptingGroupUIState.Error("Couldn't load this group") + return@launch + } + + uiState = SelectAdoptingGroupUIState.Loaded( + childRoom = childRoom, + candidates = chatRepository.adoptingCandidatesFor( + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey + ) + ) + } + } + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child, then + * hands back. + * + * Checked again here rather than trusted from the list, because the screen + * may have been open a while and a screen not drawing something is not a + * guard. The manager checks a third time; this one exists to say *why* rather + * than to throw. + */ + fun offerTo(adoptingChatRoomId: String, onDone: (String?) -> Unit) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val refusal = chatRepository.refuseAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (refusal != null) { + isActionPending.value = false + onDone(refusal) + return@launch + } + + val proposed = chatRepository.proposeAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + isActionPending.value = false + + if (!proposed) { + logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId") + onDone("Couldn't ask that group to adopt this one. Please try again.") + return@launch + } + + onDone(null) + } + } + + companion object { + private const val TAG = "SelectAdoptingGroupViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectAdoptingGroupViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index e8fbf0f3..c0647b39 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -53,7 +53,7 @@ sealed interface ChatRoomDetailUIState { * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. */ - val parentChatRoomId: HexKey? = null, + val parentChatRoomIds: List = emptyList(), /** * Whether this device could open a subgroup here at all. * @@ -68,6 +68,29 @@ sealed interface ChatRoomDetailUIState { * missing and what to do about it. */ val canAddSubgroup: Boolean = false, + /** + * Groups that have offered to adopt this one and are not parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption + * needs, showing up here as a list that is empty for everybody else. + * + * Each is verified: the signature was checked against the group it names, + * which needs no lookup. What is unverified is nothing; what is + * *unsettled* is everything, because an offer is not a lineage until this + * group's own quorum says so. + */ + val adoptionOffers: List = emptyList(), + /** + * Whether this device could put a parent to this group at all. + * + * The room has to be a Marmot one with a key state -- the certificate + * carries the child's threshold key and the acceptance re-announces that + * state -- and this device has to hold a share, because both halves are + * signatures by this group. The rest of the refusals are the picker's, + * where they can name which group and why. + */ + val canAddParent: Boolean = false, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt new file mode 100644 index 00000000..0e9e533e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt @@ -0,0 +1,68 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep + +sealed interface DkgRitualListUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Every ceremony the room holds, newest first. */ + val ceremonies: List = emptyList(), + ): DkgRitualListUIState { + /** + * The ones that cannot move until this member acts. + * + * Split out rather than sorted to the top, the way the proposal list + * splits its own -- the rest of the list is what the room has done, + * these are what it is waiting on the reader for. A room can have two + * at once the moment a subgroup's admins are the whole group, and + * burying the second under the first one's history is the whole reason + * this screen exists. + */ + val waitingForYou: List = ceremonies.filter { it.awaitsYou } + + val rest: List = ceremonies.filterNot { it.awaitsYou } + } + + /** + * One ceremony, as a row. + * + * Both reasons a ceremony can want something are carried separately rather + * than folded into one flag, because they are owed by different people and + * read differently: an approval is this member's gate and nobody else can + * pass it, while finishing the setup is work the coordinator alone can come + * back to. The row says which. + */ + data class Ceremony( + val session: DkgSession, + + /** Whoever opened it, as a name to read. */ + val coordinatorName: String, + + /** What the ceremony is waiting on this member for, if anything. */ + val pendingApproval: DkgApprovalStep? = null, + + /** + * The room this finished ceremony's key derives, when it does not exist + * yet and this member is the one who would make it. + * + * Reaching COMPLETE finishes nothing on its own: the group has a key and + * somebody still has to get its state signed and create the room. The + * same reading the transcript's standing notice makes. + */ + val roomAwaitingCreation: String? = null, + ) { + /** Whether this ceremony is making a subgroup rather than the room's own key. */ + val isSubgroup: Boolean get() = session.parentChatRoomId != null + + val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null + } + + data class Error( + val message: String + ): DkgRitualListUIState + + data object Loading: DkgRitualListUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt new file mode 100644 index 00000000..9255519b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface SelectAdoptingGroupUIState { + data class Loaded( + val childRoom: LocalChatRoom, + /** + * The groups this profile could ask, in room order. + * + * **The profile's own rooms, and nothing else is possible.** A + * certificate is proposed *inside* the adopting group, so a candidate has + * to be a room the key in the chair can already act in. Empty is a fact + * about this member rather than a failure -- a group cannot be offered as + * a parent by somebody who is not in it, and the remedy is a person + * rather than a button. + */ + val candidates: List = emptyList(), + ): SelectAdoptingGroupUIState + + data class Error(val message: String): SelectAdoptingGroupUIState + + data object Loading: SelectAdoptingGroupUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index baf9a15e..48892ee3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -25,7 +25,10 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage +import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent @@ -72,6 +75,15 @@ class GroupKeyStateTest { threshold = threshold ) + /** A third group, for the lineages that name two parents. */ + private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() /** The other group's key, for the states that name the wrong one. */ @@ -304,15 +316,15 @@ class GroupKeyStateTest { fun `a state the group signed about the room it arrives in is taken`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertEquals(chatRoomId, state?.chatRoomId) - assertEquals("ceremony-1", state?.dkgSessionId) - assertEquals(thresholdPublicKey, state?.thresholdPublicKey) - assertEquals("m/9420/0/0", state?.derivationPath) + assertEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals("ceremony-1", state?.keyState?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.keyState?.derivationPath) // Attribution and ordering come off the event, not off the clock. The // author is the room itself now, which is the whole point: nobody in // particular said this. - assertEquals(chatRoomId, state?.announcedBy) - assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + assertEquals(chatRoomId, state?.keyState?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt) } @Test @@ -409,8 +421,8 @@ class GroupKeyStateTest { ) ) - assertNotEquals(chatRoomId, state?.chatRoomId) - assertEquals(siblingRoom, state?.chatRoomId) + assertNotEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals(siblingRoom, state?.keyState?.chatRoomId) } @Test @@ -472,7 +484,8 @@ class GroupKeyStateTest { assertEquals( chatRoomId, - GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId + GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected)) + ?.keyState?.chatRoomId ) } @@ -757,6 +770,23 @@ class GroupKeyStateTest { /** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */ private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex + /** A second, unrelated parent's room -- the group that adopts the child later. */ + private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex + + /** A lineage of both parents, each with its own group's real signature. */ + private fun bothParents() = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) + ) + ) + /** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */ private fun birthCertificate( subgroupChatRoomId: String = chatRoomId, @@ -786,7 +816,9 @@ class GroupKeyStateTest { chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - parent = SubgroupParentage(parentChatRoomId = parent, certificate = certificate) + lineage = SubgroupLineage.of( + SubgroupParentage(parentChatRoomId = parent, certificate = certificate) + ) ) ) @@ -794,18 +826,26 @@ class GroupKeyStateTest { fun `a state whose parent certified it keeps the parent and the certificate`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent()) - assertEquals(parentChatRoomId, state?.parentChatRoomId) - assertEquals(birthCertificate().toJson(), state?.birthCertificateJson) + assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds()) + assertEquals( + birthCertificate().toJson(), + state?.parents?.single()?.birthCertificateJson + ) + // And the row points back at the child, which is what makes it a row + // rather than a column. + assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId) } @Test - fun `a state claiming no parent keeps both fields null`() { + fun `a state claiming no parent carries no parent rows at all`() { // Every group made before subgroups existed, and every top-level group - // made after. Null here is the ordinary case, not missing data. + // made after. No rows is the ordinary case, not missing data -- and it is + // one way of saying "nobody's child" rather than two, which is why a + // lineage of none is null rather than empty. val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertNull(state?.parentChatRoomId) - assertNull(state?.birthCertificateJson) + assertEquals(emptyList(), state?.parents) + assertNull(state?.lineage()) } @Test @@ -892,19 +932,183 @@ class GroupKeyStateTest { chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - parent = SubgroupParentage(parentChatRoomId, certificate) + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) ) assertTrue(GroupKeyStateEvent.claimsParentage(tags)) - assertEquals(parentChatRoomId, GroupKeyStateEvent.parseParentChatRoomId(tags)) - assertEquals(certificate.toJson(), GroupKeyStateEvent.parseBirthCertificate(tags)?.toJson()) + + val lineage = GroupKeyStateEvent.parseLineage(tags) + + assertEquals(listOf(parentChatRoomId), lineage?.parentChatRoomIds) + assertEquals(certificate.toJson(), lineage?.parents?.single()?.certificate?.toJson()) // And the tags a top-level group is proposed on carry neither, so every // state signed before subgroups existed reads exactly as it did. val plain = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) assertFalse(GroupKeyStateEvent.claimsParentage(plain)) - assertNull(GroupKeyStateEvent.parseParentChatRoomId(plain)) - assertNull(GroupKeyStateEvent.parseBirthCertificate(plain)) + assertNull(GroupKeyStateEvent.parseLineage(plain)) + } + + @Test + fun `two parents land as adjacent pairs, each id before its own certificate`() { + // The one thing in this design that exists for *other people's* builds. A + // reader that predates several parents takes the first `parent_group` and + // the first `birth_certificate` and checks them against each other, so + // the pairs have to be adjacent and in step. Were they emitted as all the + // ids and then all the certificates, such a reader would pair one + // parent's id with another's certificate, fail `certifies`, and drop the + // whole state -- losing the row that says which share signs for the room. + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = bothParents() + ) + + val names = tags.map { it[0] }.filter { + it == SubgroupParentTag.TAG_NAME || it == SubgroupBirthCertificateTag.TAG_NAME + } + + assertEquals( + listOf( + SubgroupParentTag.TAG_NAME, + SubgroupBirthCertificateTag.TAG_NAME, + SubgroupParentTag.TAG_NAME, + SubgroupBirthCertificateTag.TAG_NAME + ), + names + ) + + // And the first pair an older reader would take is a matched one: the + // certificate beside the first id is the certificate *by* that parent. + val firstParent = tags.first { it[0] == SubgroupParentTag.TAG_NAME }[1] + val firstCertificate = tags.first { it[0] == SubgroupBirthCertificateTag.TAG_NAME }[1] + + assertEquals(firstParent, Event.fromJson(firstCertificate).pubKey) + } + + @Test + fun `a state claiming two parents keeps both, each with its own certificate`() { + // Two groups, two quorums, two signatures over the same child's id. The + // second one signed later and could not have named the first as a + // co-parent, which is why nothing requires the certificates to agree on + // the set -- the child's key state is the sole authority on it. + val state = signedKeyState( + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = bothParents() + ) + ) + + val local = GroupKeyStateManager.stateFrom(chatRoomId, state) + + assertEquals( + listOf(parentChatRoomId, secondParentChatRoomId).sorted(), + local?.parentChatRoomIds()?.sorted() + ) + assertEquals(2, local?.parents?.size) + // Each row carries the certificate of the parent it names, not whichever + // one came first off the tags. + local?.parents?.forEach { parent -> + assertEquals( + parent.parentChatRoomId, + Event.fromJson(parent.birthCertificateJson).pubKey, + "each parentage row keeps its own parent's certificate" + ) + } + } + + @Test + fun `one bad certificate in a set of two drops the whole state`() { + // All or nothing, generalised. Keeping the provable half would file a + // group as having one parent when its own members see two, and a set + // that is partly believed is not a weaker set. + val state = signedKeyState( + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + // Really signed by the second parent, but for a different + // child -- so it certifies something, just not this. + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + subgroupChatRoomId = otherKey, + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) + ) + ) + ) + ) + + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, state)) + } + + @Test + fun `a state naming the same parent twice is dropped`() { + val certificate = birthCertificate() + val doubled = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf( + SubgroupParentTag.assemble(parentChatRoomId), + SubgroupBirthCertificateTag.assemble(certificate) + ) + + assertTrue(GroupKeyStateEvent.claimsParentage(doubled)) + assertNull(GroupKeyStateEvent.parseLineage(doubled)) + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = doubled))) + } + + @Test + fun `an uneven number of halves is refused, not completed from the other`() { + val certificate = birthCertificate() + + val extraParent = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf(SubgroupParentTag.assemble(chatRoomId)) + + assertNull(GroupKeyStateEvent.parseLineage(extraParent)) + + val extraCertificate = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf(SubgroupBirthCertificateTag.assemble(certificate)) + + assertNull(GroupKeyStateEvent.parseLineage(extraCertificate)) + } + + @Test + fun `a half that is present and broken shifts nothing, because it is counted`() { + // Counted by tag name as well as by successful parse, so a broken half is + // told from one that was never written. Only the first would slide the + // pairing along by one and pair an id with somebody else's certificate. + val broken = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, birthCertificate())) + ) + arrayOf( + SubgroupParentTag.assemble(chatRoomId), + arrayOf(SubgroupBirthCertificateTag.TAG_NAME, "not an event") + ) + + assertTrue(GroupKeyStateEvent.claimsParentage(broken)) + assertNull(GroupKeyStateEvent.parseLineage(broken)) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index 8fc32ffd..ada04fb2 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -18,6 +18,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupManager /** * What a birth certificate is allowed to convince a member of. @@ -350,6 +351,237 @@ class SubgroupBirthCertificateEventTest { * way to test that both are checked. [signature] replaces the real one * outright, for the cases where what arrives is not a signature at all. */ + // ---- What an arriving certificate is ------------------------------------ + // + // `SubgroupManager.classify` lives in a manager and is tested here, because + // every case it decides is a question about a certificate and this is where + // the three real quorums are. It needs no database: `certifies` checks a + // signature against a room id, and a room id is a public key. + + @Test + fun `in the parent's room, a certificate is about the room's own child`() { + assertEquals( + SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), + SubgroupManager.classify(parentChatRoomId, certificate()) + ) + } + + @Test + fun `in the child's room, the same certificate is an offer to adopt it`() { + // The half of an adoption the child's members would otherwise never hear + // about until a proposal asked for their signature. One event, two + // meanings, and the room it arrived in is the whole of the difference. + assertEquals( + SubgroupManager.ArrivingCertificate.OfferToUs(parentChatRoomId), + SubgroupManager.classify(subgroupChatRoomId, certificate()) + ) + } + + @Test + fun `in a third party's room it is nothing at all`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + + // Neither this room's act nor about this room. Whoever signed it, it is + // nobody's business here, and a line in this transcript would be noise + // about two groups the reader may not be in. + assertNull(SubgroupManager.classify(strangerRoom, certificate())) + } + + @Test + fun `an offer has to be signed by the group it names`() { + // The `parent_group` tag says the parent certified this room; the author + // says who actually signed. A stranger putting another group's name on + // its own signature is the forgery this check exists for. + val forged = certificate(author = strangerMaterial) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, forged)) + } + + @Test + fun `a group is not its own child, however truthfully it says so`() { + // The one self-referential certificate `certifies` waves through: author, + // subject and named parent all one room, the id deriving from the key, + // the signature verifying. Refused before either branch -- read as an + // ordinary child it would put the group in its own list of subgroups. + val itself = certificate( + content = parentChatRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(parentChatRoomId, itself)) + } + + @Test + fun `an offer must be about this room and not merely arrive in it`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + val aboutSomebodyElse = certificate( + content = strangerRoom, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = strangerRoom, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) + } + + // ---- `addresses`: the half that can be asked of a proposal ------------ + // + // The ceremony screen has to tell one *in-flight* certificate signing from + // another in the parent's room, and a parent certifies every subgroup it + // ever makes from that one room. `certifies` cannot answer it: it ends on a + // signature by the parent's quorum, which is exactly what the screen is + // waiting for. `addresses` is the structural half, and the only thing worth + // asserting about it is where it stops. + + @Test + fun `an unsigned proposal addresses its child, and certifies nothing`() { + // What the ceremony screen is actually holding: the event as it was + // assembled, before anybody signed it. Any string in `sig` serves -- + // the point is that no quorum has put one there. + val proposal = certificate(signature = "0".repeat(128)) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "the screen could not tell which subgroup its own proposal is about" + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "an unsigned proposal is being read as a parent's certificate" + ) + } + + @Test + fun `a forgery by another group addresses the pair it names, and certifies nothing`() { + // The reason `addresses` must never decide a lineage: anybody can author + // an event naming any pair, and this says yes to it. The guard is that + // nothing downstream of a lineage asks `addresses` -- only the screen + // deciding which of its own room's signings to draw does. + val forged = certificate(author = childMaterial) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a sibling's proposal is not addressed to this child`() { + // Two subgroups of one parent, which is the case the screen exists to + // tell apart. The sibling's proposal is a real, well-formed certificate + // request; it is simply about somebody else. + val siblingRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + val sibling = certificate( + content = siblingRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = "Another team", + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "one subgroup's rung is following a sibling's proposal" + ) + + // And it is addressed to the sibling, so the sibling's own screen finds + // it -- the two are told apart rather than both being refused. + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a proposal naming another parent is not addressed by this one`() { + val fromElsewhere = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path), + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = fromElsewhere, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `everything certifies addresses, so the weaker one cannot be the stricter`() { + // `certifies` calls `addresses` and adds to it, and this is what keeps + // that true: a change that let one pass where the other refuses would + // mean two different answers to "which child is this about". + val valid = certificate() + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt new file mode 100644 index 00000000..7c8c31df --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The two invariants a lineage holds, and the three edits it allows. + * + * Nothing here signs anything. `SubgroupLineage` is about the *shape* of a + * parentage set -- no repeats, one canonical order -- and the certificates in it + * are deliberately nonsense, because whether a certificate is good is + * `SubgroupBirthCertificateEvent.certifies`' question and is asked on every read + * rather than at the door. A test here that needed a real signature would be + * testing the wrong class. + */ +class SubgroupLineageTest { + private val alpha = "a".repeat(64) + private val beta = "b".repeat(64) + private val gamma = "c".repeat(64) + + @Test + fun `no parents is no lineage rather than an empty one`() { + assertNull(SubgroupLineage.of(emptyList())) + } + + @Test + fun `one parent cannot fail, because it has nothing to repeat`() { + val lineage = SubgroupLineage.of(parentage(alpha)) + + assertEquals(1, lineage.size) + assertEquals(listOf(alpha), lineage.parentChatRoomIds) + assertTrue(lineage.names(alpha)) + assertFalse(lineage.names(beta)) + } + + @Test + fun `the same parent twice is refused rather than folded`() { + // One parent asked twice, carrying two certificates. Folding them would + // be this device picking which of the parent's statements counts. + assertNull(SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha)))) + } + + @Test + fun `a repeat is a repeat whatever case it is written in`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha.uppercase()))) + + assertNull(lineage) + } + + @Test + fun `a room id is matched case-insensitively, because it is hex`() { + val lineage = SubgroupLineage.of(parentage(alpha)) + + assertTrue(lineage.names(alpha.uppercase())) + } + + @Test + fun `the order is the parents' ids, whatever order they arrived in`() { + // The whole reason this class sorts: two devices assembling one lineage + // from the same certificates have to emit the same bytes, or a reader + // that pairs the first tag of each name gets a mismatched pair. + val forwards = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta), parentage(gamma))) + val backwards = SubgroupLineage.of(listOf(parentage(gamma), parentage(beta), parentage(alpha))) + + assertEquals(listOf(alpha, beta, gamma), forwards?.parentChatRoomIds) + assertEquals(listOf(alpha, beta, gamma), backwards?.parentChatRoomIds) + } + + @Test + fun `sorting is on the id and not on the case it is written in`() { + val lineage = SubgroupLineage.of(listOf(parentage(beta.uppercase()), parentage(alpha))) + + // Sorted by the lowercased id, and the id itself is kept as it arrived -- + // the certificate is the authority on what the parent is called, and this + // tag is only an index into it. + assertEquals(listOf(alpha, beta.uppercase()), lineage?.parentChatRoomIds) + } + + @Test + fun `adding a parent is an addition, not a replacement`() { + // A key state is replaceable and the newest wins wholesale, so an + // adoption that carried only the new parent would silently drop the old + // one. Expressing it as an addition is what stops a caller assembling + // the resulting set by hand and forgetting half of it. + val adopted = SubgroupLineage.of(parentage(beta)).plus(parentage(alpha)) + + assertEquals(listOf(alpha, beta), adopted?.parentChatRoomIds) + } + + @Test + fun `adding a parent that is already there is refused`() { + assertNull(SubgroupLineage.of(parentage(alpha)).plus(parentage(alpha))) + } + + @Test + fun `dropping the last parent leaves no lineage at all`() { + // Null is "nobody's child", which is what a caller passes on. It revokes + // nothing: the certificate is a signature and exists forever, and what + // this removes is the child's half of the link. + assertNull(SubgroupLineage.of(parentage(alpha)).minus(alpha)) + } + + @Test + fun `dropping one of two leaves the other`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta))) + + assertEquals(listOf(beta), lineage?.minus(alpha)?.parentChatRoomIds) + assertEquals(listOf(alpha), lineage?.minus(beta)?.parentChatRoomIds) + } + + @Test + fun `dropping a parent that was never there changes nothing`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta))) + + assertEquals(listOf(alpha, beta), lineage?.minus(gamma)?.parentChatRoomIds) + } + + @Test + fun `the certificates ride along with their own parents`() { + // The pairing is the point of the type: an id and the certificate beside + // it have to stay together through the sort, or `certifies` would be + // asked about a pair the group never signed. + val lineage = SubgroupLineage.of(listOf(parentage(gamma), parentage(alpha))) + val parents = lineage?.parents.orEmpty() + + assertEquals(alpha, parents[0].parentChatRoomId) + assertEquals(alpha, parents[0].certificate.content) + assertEquals(gamma, parents[1].parentChatRoomId) + assertEquals(gamma, parents[1].certificate.content) + } + + @Test + fun `a parentage is carried as it was handed over`() { + val parentage = parentage(alpha) + + assertSame(parentage, SubgroupLineage.of(parentage).parents.single()) + } + + /** + * A parentage whose certificate says only which parent it belongs to, so a + * test can tell two of them apart after a sort. Not a valid certificate and + * not meant to be one. + */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = Event( + id = "d".repeat(64), + pubKey = parentChatRoomId, + createdAt = 1_700_000_000, + kind = SubgroupBirthCertificateEvent.KIND, + tags = emptyArray(), + content = parentChatRoomId, + sig = "e".repeat(128) + ) + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 5992bab2..ce727207 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -7,6 +7,10 @@ import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEven import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage /** * How an event a group signs is said to the member signing it. @@ -151,6 +155,91 @@ class ProposedEventTest { assertEquals(translationId.take(12), summary.detail) } + @Test + fun `a key state with no lineage reads as the key alone`() { + // Every group made before subgroups, and every top-level group made + // after. The path and the ceremony are what a signer needs, because the + // only question is which key the room signs with. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + ) + ) + + assertEquals("This group's shared key", summary.label) + assertEquals("m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state that names a parent says so first`() { + // The one change in this mapping that is not cosmetic. A key state can be + // re-announced now, and that is how a group is adopted -- so a member + // approving this is deciding whether their group should answer to + // another, and a derivation path answers a question nobody asked. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(parentage(parent)) + ) + ) + ) + + assertEquals("This group's shared key, and the group it answers to", summary.label) + assertEquals("${parent.take(16)} · m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state naming two parents counts them`() { + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(listOf(parentage(parent), parentage(second))) + ) + ) + ) + + assertEquals("This group's shared key, and the 2 groups it answers to", summary.label) + // In the lineage's canonical order, so two devices describe one proposal + // the same way. + assertEquals( + "${parent.take(16)} · ${second.take(16)} · m/9420/0/0 · ceremony-1", + summary.detail + ) + } + + @Test + fun `a key state claiming a lineage it cannot state reads as the key alone`() { + // A half-claim, which `GroupKeyStateManager.stateFrom` would drop whole. + // The summary cannot say "and one group" about a parent it could not + // read, so it says nothing about lineage rather than guessing. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + + arrayOf(arrayOf("parent_group", parent)) + ) + ) + + assertEquals("This group's shared key", summary.label) + } + + private val room = "1".repeat(64) + private val parent = "a".repeat(64) + private val second = "b".repeat(64) + + /** A parentage whose certificate is nonsense; this mapping checks nothing. */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) + ) + private fun event( kind: Int, tags: Array> = emptyArray(), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt index cb040477..1e1a9e9f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt @@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.DkgRitualStage /** - * What schema 17 has to survive, and the one thing it must refuse to do. + * What schema 21 has to survive, and the two things it must refuse to do. * - * Four nullable columns say a group is somebody's child. Three properties of - * them are invisible to the compiler and each one is a way subgroups could break - * quietly. + * A room's parentage is a table now rather than two nullable columns, because a + * room can be the child of more than one group -- founded with one and adopted by + * another later. Four properties of that are invisible to the compiler and each + * one is a way a lineage could break quietly. * * A parent pointer **may name a room this device does not have**. A member of a * subgroup who was never in its parent holds the id and nothing else, so a - * dangling value is the normal state rather than an error -- and if any of these - * columns were ever given a foreign key, that member's rows would stop being + * dangling value is the normal state rather than an error -- and if the parent + * side were ever given a foreign key, that member's rows would stop being * writable at all. * * Deleting a parent **must not touch its children**. Every other reference to a - * room in this schema cascades. A self-referential cascade here would mean a user + * room in this schema cascades. A self-referential cascade would mean a user * tidying away a group they had left silently destroying a group they are still * in, together with its messages, participants, key state and signed work. * - * And the two `GroupKeyState` columns **travel together**. One without the other - * is a state whose parentage cannot be rechecked from the row, which is the whole - * reason the certificate is stored whole rather than as a signature. + * A state's lineage **is replaced wholesale, never merged**. A key state is + * replaceable and the newest wins, so the set of parents it names is the set. + * That is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and merged rows would leave the dropped parent + * surviving the statement that dropped it. + * + * And a lineage **does not outlive the state that claimed it**, which is the one + * cascade here that is wanted. */ class SubgroupDaoJvmTest { @@ -59,83 +66,72 @@ class SubgroupDaoJvmTest { /** A parent this device has never held a room for. */ private val absentParent = "33".repeat(32) - private val certificate = - """{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}""" + private fun certificate(by: String) = + """{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" + + """"tags":[],"content":"cc","sig":"dd"}""" @Test - fun `a key state keeps its parent and the certificate behind it`() = runBlocking { + fun `a key state keeps every parent and the certificate behind each`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) ) - val stored = db.groupKeyStateDao().getByChatRoomId(childRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom) - assertEquals(parentRoom, stored?.parentChatRoomId) - assertEquals(certificate, stored?.birthCertificateJson) + assertEquals( + listOf(parentRoom, absentParent).sorted(), + stored?.parents?.map { it.parentChatRoomId }?.sorted() + ) + assertEquals( + certificate(parentRoom), + stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson + ) } @Test - fun `a group that is nobody's child keeps both columns null`() = runBlocking { + fun `a group that is nobody's child carries no parent rows`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom)) + db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom)) - val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom) // Every group made before subgroups existed reads back this way, and so - // does every top-level group made after. Null here is not missing data. - assertNull(stored?.parentChatRoomId) - assertNull(stored?.birthCertificateJson) + // does every top-level group made after. No rows is not missing data, and + // it is one way of saying "nobody's child" rather than two. + assertEquals(emptyList(), stored?.parents) + assertNull(stored?.lineage()) } @Test - fun `a key state may name a parent this device holds no room for`() = runBlocking { + fun `a parentage may name a parent this device holds no room for`() = runBlocking { seedRooms() // The position a subgroup member who was never in the parent is in: they - // hold the id off a certificate and nothing else. If this column were a - // foreign key their state could not be written at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + // hold the id off a certificate and nothing else. Were the parent side a + // foreign key their lineage could not be written at all. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(absentParent)) ) - assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) - assertNull(db.chatRoomDao().findChatRoomById(absentParent)) - } - - @Test - fun `a room may name a parent this device holds no room for`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent)) - assertEquals( - absentParent, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(absentParent), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) + assertNull(db.chatRoomDao().findChatRoomById(absentParent)) } @Test fun `deleting a parent room leaves its subgroup standing`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) ) db.chatRoomDao().delete(room(parentRoom)) @@ -146,10 +142,27 @@ class SubgroupDaoJvmTest { // them owns. assertNull(db.chatRoomDao().findChatRoomById(parentRoom)) assertEquals( - parentRoom, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) - assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) + } + + @Test + fun `deleting the child takes its lineage with it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) + ) + + // The one cascade that is wanted: a lineage must not outlive the state + // that claimed it, or a room recreated at the same derived id would + // inherit a parentage nobody signed for it. + db.chatRoomDao().delete(room(childRoom)) + + assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom)) + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) } @Test @@ -159,73 +172,130 @@ class SubgroupDaoJvmTest { val second = "44".repeat(32) db.chatRoomDao().upsert(room(second)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(1_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) ) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = second, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(2_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom, child = second)) ) // Another group's child, to prove the filter is on the parent rather // than on having a parent at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = parentRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = parentRoom), + listOf(parentage(absentParent, child = parentRoom)) ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId } + db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId } ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId } + db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first() + .map { it.keyState.chatRoomId } ) } @Test - fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking { + fun `a child with two parents appears in both their lists`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) + db.chatRoomDao().upsert(room(absentParent)) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) + ) - val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first() - - assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id }) - - // A child that was certified but whose room was never created here shows - // up nowhere in this query. That is the difference between it and - // SubgroupManager.subgroupsOf, which reads the parent's certificates. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty()) - } - - @Test - fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.chatRoomDao().upsert( - room(childRoom).copy( - parentChatRoomId = parentRoom, - deletedAt = Instant.fromEpochSeconds(9_000) + // The row that makes it this parent's child says nothing about who else + // certified it, which is the whole point of the join. + listOf(parentRoom, absentParent).forEach { parent -> + assertEquals( + listOf(childRoom), + db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId }, + "$parent should list the child it certified" ) + } + } + + @Test + fun `a newer state replaces the lineage rather than adding to it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom), parentage(absentParent)) ) - // A room the user has cleared away must not come back because its parent - // still lists it. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty()) + // A disavowal: the child re-announces without one of them. Were these + // rows merged, the dropped parent would survive the statement dropping + // it -- and a parent nobody can drop is a parent for life. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty()) + } + + @Test + fun `a re-announcement to nobody's child clears the lineage`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) + ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)) + ) + + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) + } + + @Test + fun `an older state changes neither the state nor the lineage on file`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + // A session replayed after a crash must not walk the room back to a + // state it has already moved past -- and must not walk its lineage back + // either, which is new: the parents are part of the statement now. + val kept = db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(absentParent)) + ) + + assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt) + assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId }) + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + } + + @Test + fun `one parent gets one row, whatever a caller tries`() = runBlocking { + seedRooms() + + // The primary key is the rule "one certificate per child per parent". + // `SubgroupLineage.of` refuses a repeat long before here; this is the + // store saying so too, so the rule does not rest on a reader remembering. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(parentRoom)) + ) + + assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size) } @Test @@ -271,10 +341,15 @@ class SubgroupDaoJvmTest { mlsGroupState = null, ) + private fun parentage(parentChatRoomId: String, child: String = childRoom) = + GroupKeyStateParent( + chatRoomId = child, + parentChatRoomId = parentChatRoomId, + birthCertificateJson = certificate(parentChatRoomId), + ) + private fun keyState( chatRoomId: String, - parentChatRoomId: String? = null, - birthCertificateJson: String? = null, announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000) ) = GroupKeyState( chatRoomId = chatRoomId, @@ -283,8 +358,6 @@ class SubgroupDaoJvmTest { derivationPath = "m/9420/0/0", announcedBy = user, announcedAt = announcedAt, - parentChatRoomId = parentChatRoomId, - birthCertificateJson = birthCertificateJson, ) private fun session( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 05c24f91..90bd88d6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -11,6 +11,7 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.bitcoin.crypto.frost.Frost import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import fr.acinq.bitcoin.crypto.frost.Session @@ -193,7 +194,7 @@ class SignedGroupKeyStateTest { /** The one event a session of one signs. */ suspend fun item(sessionId: String) = items(sessionId).single() - suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) + suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId) /** Everything the group has signed here, as the group signed it. */ suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId) @@ -514,13 +515,13 @@ class SignedGroupKeyStateTest { listOf(creator, other).forEach { device -> val state = device.keyState() assertNotNull(state, "every signer should hold the state the group signed") - assertEquals(thresholdPublicKey, state.thresholdPublicKey) - assertEquals(ceremonyId, state.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath) + assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey) + assertEquals(ceremonyId, state.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath) // Signed by the room, so attributed to the room -- nobody in // particular said this. - assertEquals(adminRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(adminRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } } @@ -588,7 +589,7 @@ class SignedGroupKeyStateTest { absent.keyState(), "a member the quorum did not need still learns what the room signs with" ) - assertEquals(adminRoomId, absent.keyState()?.announcedBy) + assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy) } // ---- The key state, signed before the room exists ---------------------- @@ -721,22 +722,22 @@ class SignedGroupKeyStateTest { val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId) assertNotNull(adopted, "a room should adopt the state its group signed for it") - assertEquals(adminRoomId, adopted.chatRoomId) - assertEquals(thresholdPublicKey, adopted.thresholdPublicKey) - assertEquals(ceremonyId, adopted.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath) - assertEquals(adminRoomId, adopted.announcedBy) - assertTrue(adopted.verifies()) + assertEquals(adminRoomId, adopted.keyState.chatRoomId) + assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey) + assertEquals(ceremonyId, adopted.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath) + assertEquals(adminRoomId, adopted.keyState.announcedBy) + assertTrue(adopted.keyState.verifies()) // On file, not merely returned. Compared field by field because the // store keeps whole seconds and the row in hand does not. val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId) assertNotNull(stored) - assertEquals(adopted.chatRoomId, stored.chatRoomId) - assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey) - assertEquals(adopted.dkgSessionId, stored.dkgSessionId) - assertEquals(adopted.derivationPath, stored.derivationPath) - assertEquals(adopted.announcedAt, stored.announcedAt) + assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId) + assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey) + assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId) + assertEquals(adopted.keyState.derivationPath, stored.derivationPath) + assertEquals(adopted.keyState.announcedAt, stored.announcedAt) } } @@ -762,14 +763,14 @@ class SignedGroupKeyStateTest { assertNotNull(first) assertNotNull(second) - assertEquals(first.thresholdPublicKey, second.thresholdPublicKey) - assertEquals(first.dkgSessionId, second.dkgSessionId) - assertEquals(first.derivationPath, second.derivationPath) - assertEquals(first.announcedAt, second.announcedAt) + assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey) + assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId) + assertEquals(first.keyState.derivationPath, second.keyState.derivationPath) + assertEquals(first.keyState.announcedAt, second.keyState.announcedAt) // The second adopt is a no-op rather than a rewrite: `replace` keeps the // state already on file when nothing newer has been signed. To whole // seconds, which is all the store keeps. - assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds) + assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds) } @Test @@ -1379,9 +1380,9 @@ class SignedGroupKeyStateTest { val state = creator.keyState() assertNotNull(state, "a room off the default path must still reach a signed state") - assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath) - assertEquals(siblingRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath) + assertEquals(siblingRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } @Test @@ -1554,9 +1555,11 @@ class SignedGroupKeyStateTest { localChatRoom = creator.room, userPublicKey = creator.publicKey, key = ceremonyOn(creator), - parent = SubgroupParentage( - parentChatRoomId = parentRoomId, - certificate = certificate + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentRoomId, + certificate = certificate + ) ) ) pump(creator, other) @@ -1569,10 +1572,11 @@ class SignedGroupKeyStateTest { // The whole join. Neither device was sent a row; both derived the // event from their own items, both re-ran `certifies` against the - // parent's id, and both wrote the same two columns. - assertEquals(parentRoomId, state.parentChatRoomId) - assertEquals(certificate.toJson(), state.birthCertificateJson) - assertTrue(state.verifies()) + // parent's id, and both wrote the same parentage row. + assertEquals(listOf(parentRoomId), state.parentChatRoomIds()) + assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson) + assertEquals(adminRoomId, state.parents.single().chatRoomId) + assertTrue(state.keyState.verifies()) } } @@ -1591,9 +1595,11 @@ class SignedGroupKeyStateTest { localChatRoom = creator.room, userPublicKey = creator.publicKey, key = ceremonyOn(creator), - parent = SubgroupParentage( - parentChatRoomId = parentRoomId, - certificate = birthCertificate(subgroupChatRoomId = parentRoomId) + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentRoomId, + certificate = birthCertificate(subgroupChatRoomId = parentRoomId) + ) ) ) } @@ -1621,8 +1627,8 @@ class SignedGroupKeyStateTest { // does every top-level group made after. listOf(creator, other).forEach { device -> val state = assertNotNull(device.keyState()) - assertNull(state.parentChatRoomId) - assertNull(state.birthCertificateJson) + assertEquals(emptyList(), state.parents) + assertNull(state.lineage()) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index 5953e669..32ee1bd3 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -34,6 +34,8 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Participant @@ -41,6 +43,7 @@ import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent /** @@ -501,6 +504,326 @@ class SubgroupManagerJvmTest { * room first, because `DkgSession.chatRoomId` is a foreign key onto it. */ /** A ceremony held in the parent's room to make a subgroup run by [admins]. */ + // ---- Adoption: a group that already exists gaining a parent ------------- + + @Test + fun `a group with a key state and an admin in both can be adopted`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertNull(refuseAdoptionOf(adopting)) + } + + @Test + fun `a group that has agreed no key state cannot be certified`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + // The room exists; it has never said what it signs with. The certificate + // carries the child's threshold key and there is none to carry, so + // `certifies` would fail its fourth question on every device. + seedRoom(childRoomId) + + assertEquals( + "This group has not agreed what it signs with, so there is nothing to certify.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a member who holds no share of the adopting group's key is refused first`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), canSign = false) + childWithKeyState() + + assertEquals( + "Only a member who holds a share of that group's key can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a non-admin of the adopting group is refused`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), isAdmin = false) + childWithKeyState() + + assertEquals( + "Only an admin of that group can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group cannot be its own parent`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertEquals( + "A group cannot be its own parent.", + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = adopting.chatRoom.id, + userPublicKey = user + ) + ) + } + + @Test + fun `a parent already in the lineage is not offered again`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + assertEquals("That group has already adopted this one.", refuseAdoptionOf(adopting)) + } + + @Test + fun `an offer that already stands is not made twice`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // One certificate per child per parent: a second is a `d`-tag + // replacement of the first rather than a second adoption. + record(certificate()) + + assertEquals( + "That group has already offered to adopt this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group already beneath this one cannot adopt it`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // The adopting group is already the child's child, so adopting it back + // would close a cycle. Advisory -- this device sees only the lineages it + // holds states for -- and the real protection is the child's own quorum. + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = parentRoomId, + dkgSessionId = "parent-ceremony", + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = parentRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + listOf( + GroupKeyStateParent( + chatRoomId = parentRoomId, + parentChatRoomId = childRoomId, + birthCertificateJson = certificate().toJson(), + ) + ) + ) + + assertEquals( + "That group is already a subgroup of this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `the picker offers only the rooms this profile can act in`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // A room this device is in but holds no share of, and one it is in but + // does not administer. Neither can carry the signature, so neither is + // offered -- and the filter runs the guard itself rather than a copy of + // its rules. + marmotRoom( + chatRoomId = siblingRoomId, + material = siblingMaterial, + ceremonyId = "sibling-ceremony", + name = "Elders", + members = listOf(alice), + canSign = false, + ) + + assertEquals( + listOf(adopting.chatRoom.id), + SubgroupManager.adoptingCandidatesFor(db, childRoomId, user).map { it.chatRoom.id } + ) + } + + @Test + fun `accepting a parent re-announces the key state with it added`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // Signed in the child's own room, which founding cannot do: the room + // exists, so `propose`'s first branch holds. + assertEquals(childRoomId, session.chatRoomId) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals(GroupKeyStateEvent.KIND, proposed.kind) + assertEquals( + listOf(parentRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `accepting a second parent carries the first one too`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // The footgun this guards: a key state is replaceable and the newest wins + // wholesale, so a state naming only the new parent would silently drop + // the one already there. + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(parentRoomId, siblingRoomId).sorted(), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds?.sorted() + ) + } + + @Test + fun `accepting a parent that has not certified this group is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + + // No certificate on file. Only a member of the adopting group has one, so + // this is the overlap requirement from the other side. + assertFailsWith { + SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + @Test + fun `disavowing a parent re-announces the key state without it`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(siblingRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `disavowing the only parent leaves a state claiming none`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + // Nobody's child, which is a state rather than an absence of one. It + // revokes nothing: the certificate is a signature and exists forever. + assertFalse(GroupKeyStateEvent.claimsParentage(proposed.tags)) + assertNull(GroupKeyStateEvent.parseLineage(proposed.tags)) + } + + @Test + fun `disavowing a parent this group never claimed is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + + assertFailsWith { + SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = childRoomId, + userPublicKey = user + ) + + /** + * The child as an adoption finds it: a real Marmot room of its own, with a + * key state saying what it signs with, and whatever lineage it already has. + */ + private suspend fun childWithKeyState( + parents: List = emptyList() + ): LocalChatRoom { + val room = marmotRoom( + chatRoomId = childRoomId, + material = childMaterial, + ceremonyId = "child-ceremony", + name = "Translators", + members = listOf(alice, bob), + ) + + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = childRoomId, + dkgSessionId = "child-ceremony", + thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = childRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + parents.map { parent -> + GroupKeyStateParent( + chatRoomId = childRoomId, + parentChatRoomId = parent, + birthCertificateJson = certificate( + author = if (parent == parentRoomId) parentMaterial else siblingMaterial, + parentChatRoomId = parent, + ).toJson(), + ) + } + ) + + return room + } + private fun subgroupCeremony(id: String, admins: Set) = ownCeremony(parentRoomId).copy( id = id, @@ -584,15 +907,40 @@ class SubgroupManagerJvmTest { members: List, canSign: Boolean = true, isAdmin: Boolean = true, + ): LocalChatRoom = marmotRoom( + chatRoomId = parentRoomId, + material = parentMaterial, + ceremonyId = "parent-ceremony", + name = "Ekklesia", + members = members, + canSign = canSign, + isAdmin = isAdmin, + ) + + /** + * Any of this app's group rooms, as what one *is*: the admin room derived + * from the group's own key, with its `MarmotGroupData` in the epoch-0 + * extensions. Adoption needs two of them at once -- the adopting group and + * the child -- so this takes the id and the key rather than closing over + * the parent's. + */ + private suspend fun marmotRoom( + chatRoomId: String, + material: KeyMaterial, + ceremonyId: String, + name: String, + members: List, + canSign: Boolean = true, + isAdmin: Boolean = true, ): LocalChatRoom { (members + user).forEach { seedProfile(it) } val admins = if (isAdmin) members + user else members val metadata = MarmotGroupData( - nostrGroupId = parentRoomId, - name = "Ekklesia", - description = SharedKeyDerivation.describe("Admins of Ekklesia."), + nostrGroupId = chatRoomId, + name = name, + description = SharedKeyDerivation.describe("Admins of $name."), adminPubkeys = admins, relays = Relays.DefaultDMRelayList.map { it.url } ) @@ -604,9 +952,9 @@ class SubgroupManagerJvmTest { db.chatRoomDao().upsert( ChatRoom( - id = parentRoomId, + id = chatRoomId, userPublicKey = user, - subject = "Ekklesia", + subject = name, description = metadata.description, mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), ) @@ -620,7 +968,7 @@ class SubgroupManagerJvmTest { (members + user).map { Participant( participantPublicKey = it, - chatRoomId = parentRoomId, + chatRoomId = chatRoomId, relayHint = null, ) } @@ -631,8 +979,8 @@ class SubgroupManagerJvmTest { // matches, which is exactly how a real admin room resolves its key. db.dkgSessionDao().upsert( DkgSession( - id = "parent-ceremony", - chatRoomId = parentRoomId, + id = ceremonyId, + chatRoomId = chatRoomId, coordinatorPublicKey = user, userPublicKey = user, threshold = 2, @@ -641,7 +989,7 @@ class SubgroupManagerJvmTest { hostPublicKey = user, round1Random = "aa".repeat(32), round2AuxRandom = "bb".repeat(32), - thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + thresholdPublicKey = material.thresholdPublicKey.value.toHex(), secretShare = "cc".repeat(32), ) ) @@ -653,7 +1001,7 @@ class SubgroupManagerJvmTest { (members + user).forEachIndexed { index, member -> db.dkgSessionDao().upsert( DkgParticipantMessage( - sessionId = "parent-ceremony", + sessionId = ceremonyId, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = "0${index + 1}".repeat(33) @@ -662,7 +1010,7 @@ class SubgroupManagerJvmTest { } } - return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId)) + return assertNotNull(db.chatRoomDao().findChatRoomById(chatRoomId)) } private suspend fun seedProfile(publicKey: String) { @@ -716,12 +1064,13 @@ class SubgroupManagerJvmTest { createdAt: Long = 1_700_000_000, author: KeyMaterial = parentMaterial, signer: KeyMaterial = author, - signature: String? = null + signature: String? = null, + parentChatRoomId: String = roomOf(author) ): Event { val subgroupChatRoomId = roomOf(subgroupMaterial) val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( subgroupChatRoomId = subgroupChatRoomId, - parentChatRoomId = parentRoomId, + parentChatRoomId = parentChatRoomId, thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), adminPublicKeys = adminPublicKeys, name = name, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt new file mode 100644 index 00000000..7d49853d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt @@ -0,0 +1,157 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.DkgRitualListUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What the ceremony list is for, read off the rendered screen. + * + * The ordering is settled in [press.mantra.compose.ui.view.model.DkgRitualListViewModel] + * and tested there. What is only true once it is drawn is that a row goes to + * the ceremony it names -- the whole reason this screen exists is that the + * screens before it could not say which of a room's ceremonies they meant -- + * and that a room with none offers the way to hold one rather than ending in a + * blank page. + */ +@OptIn(ExperimentalTestApi::class) +class DkgRitualListScreenJvmTest { + private val me = "11".repeat(32) + private val chatRoomId = "22".repeat(32) + + @Test + fun `a row opens the ceremony it names, not the room's newest`() = + runDesktopComposeUiTest(500, 1200) { + var opened: Route? = null + + setContent { + Screen( + state = loaded( + ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy( + pendingApproval = DkgApprovalStep.HOST_KEY + ), + ceremony("newest", DkgRitualStage.COMPLETE) + ), + onNavigateToRoute = { opened = it } + ) + } + + onNodeWithText("Join it by publishing your device's key") + .assertIsDisplayed() + .performClick() + + assertEquals( + DkgRitualRoute( + activeUserPublicKey = me, + chatRoomId = chatRoomId, + dkgSessionId = "subgroup" + ), + opened + ) + } + + /** The two sections, so a ceremony owing nothing cannot be mistaken for one that does. */ + @Test + fun `the ones waiting on you are headed apart from the rest`() = + runDesktopComposeUiTest(500, 1200) { + setContent { + Screen( + state = loaded( + ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy( + pendingApproval = DkgApprovalStep.HOST_KEY + ), + ceremony("newest", DkgRitualStage.COMPLETE) + ) + ) + } + + onNodeWithText("Waiting for you").assertIsDisplayed() + onNodeWithText("Everything else").assertIsDisplayed() + onNodeWithText("The group has a key from this ceremony.").assertIsDisplayed() + } + + @Test + fun `a room that has held none offers to hold one`() = + runDesktopComposeUiTest(500, 1200) { + var opened: Route? = null + + setContent { Screen(state = loaded(), onNavigateToRoute = { opened = it }) } + + onNodeWithText("This group has not held a key ceremony.").assertIsDisplayed() + onNodeWithText("Set up a shared key").assertIsDisplayed().performClick() + + // No session id: there is no ceremony to name, and the ceremony + // screen is what decides whether the group can hold one at all. + assertEquals( + DkgRitualRoute(activeUserPublicKey = me, chatRoomId = chatRoomId), + opened + ) + } + + @Composable + private fun Screen( + state: DkgRitualListUIState, + onNavigateToRoute: (Route) -> Unit = {}, + ) { + MantraTheme { + ProvideSnackbarHost { + DkgRitualListScreen( + activeUserPublicKey = me, + chatRoomId = chatRoomId, + initialDkgRitualListUIState = state, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + + private fun loaded(vararg ceremonies: DkgRitualListUIState.Ceremony) = + DkgRitualListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ) + ), + ceremonies = ceremonies.toList() + ) + + private fun ceremony(id: String, stage: DkgRitualStage) = DkgRitualListUIState.Ceremony( + session = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ), + coordinatorName = "You", + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt new file mode 100644 index 00000000..f6c876cb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -0,0 +1,216 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ChatMessageListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * When tapping one ceremony in the transcript is answering the whole question, + * and when it is answering a third of it. + * + * The transcript names one ceremony per line and its standing notice stands for + * however many are owed. Opening the named one is right while it is the only + * thing being asked; it stops being right the moment there are two, because the + * second is not on screen beside the first -- it may be pages up, or have + * arrived while the member was reading -- so answering the one they tapped and + * leaving would look exactly like being done. + * + * `hidesOtherDecisions` has made this call for proposals since before subgroups + * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart, + * and a room holds several ceremonies as a matter of course now that a + * subgroup's ChillDKG runs in its parent's room. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CeremonyQueueRoutingJvmTest { + private val chatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `one ceremony owed is answered by opening it`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)), + pending = mapOf("only" to DkgApprovalStep.ROUND_1), + expected = 1 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("only"), + "the one ceremony owed was sent to a queue of itself" + ) + } + + @Test + fun `two ceremonies owed send the member to the list instead`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = chatRoomId, subject = "Translators"), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + // Either tap: the queue is the honest answer to both, because neither is + // the whole of what the room is waiting on this member for. + assertTrue(viewModel.hidesOtherCeremonies("ours")) + assertTrue(viewModel.hidesOtherCeremonies("subgroup")) + } + + /** + * The restraint, and the reason this is not just "does the room owe two". + * A line about a ceremony the group finished is history, and a reader who + * taps one is asking to see that -- not to be handed a queue of other + * people's business. + */ + @Test + fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("done", DkgRitualStage.COMPLETE), + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("done"), + "a finished ceremony's line was answered with somebody else's queue" + ) + } + + /** + * An abandoned one is not owed either, which matters because the room's + * watcher filters on it and a row that slipped through would put a dead + * ceremony at the top of the notice. + */ + @Test + fun `an abandoned ceremony is never counted as owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Says yes, and has to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + expected = 0 + ) + + assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty()) + assertFalse(viewModel.hidesOtherCeremonies("abandoned")) + } + + /** Builds the view model and waits until its watcher has settled on [expected]. */ + private suspend fun awaiting( + sessions: List, + pending: Map, + expected: Int, + ): ChatMessageListViewModel { + val viewModel = ChatMessageListViewModel( + initialChatMessageListUIState = ChatMessageListUIState.Loading, + localChatRoom = room(), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + } + ) + + viewModel.initiate() + + // The watcher writes once, so an empty expectation cannot be waited for + // by its own value -- it is indistinguishable from not having run. Wait + // for a non-empty one, and give an empty one a moment to be wrong in. + withTimeout(5_000) { + if (expected == 0) { + delay(200) + } else { + while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5) + } + } + + assertEquals(expected, viewModel.ceremoniesAwaitingYou.size) + return viewModel + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = peer, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = peer, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt new file mode 100644 index 00000000..80798701 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt @@ -0,0 +1,198 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the key-ceremony list decides that nothing else can check. + * + * **Which ceremonies come first.** A room holds one per subgroup the group has + * ever made plus its own, and the newest is routinely the finished one -- so + * the list is only worth having if the two that want something from the reader + * are at the top of it rather than wherever their dates put them. + * + * **What counts as wanting something.** Two different things do, owed by + * different people: an approval gate this member cannot delegate, and a + * finished ceremony whose room only its coordinator can create. An abandoned + * ceremony wants neither, whatever the rows underneath it say. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualListViewModelJvmTest { + private val chatRoomId = "a1b2c3d4".repeat(8) + private val me = "11".repeat(32) + private val ada = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the ceremonies waiting on this member are separated from the rest`() = runBlocking { + val mine = session("mine", DkgRitualStage.COLLECTING_ROUND_1) + val finished = session("finished", DkgRitualStage.COMPLETE) + val subgroup = session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = "parent", subject = "Translators") + + val loaded = load( + // Newest first, the way the query returns them -- and the one that + // wants something is deliberately not the newest. + sessions = listOf(finished, subgroup, mine), + pending = mapOf( + "mine" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ) + ) + + assertEquals( + listOf("subgroup", "mine"), + loaded.waitingForYou.map { it.session.id }, + "a ceremony waiting on this member was left down the list" + ) + assertEquals(listOf("finished"), loaded.rest.map { it.session.id }) + assertTrue(loaded.waitingForYou.single { it.session.id == "subgroup" }.isSubgroup) + } + + /** + * A finished ceremony still owes its coordinator a room, and owes everybody + * else nothing -- a row telling the rest of the group to create it would be + * telling them to do something they cannot. + */ + @Test + fun `only the coordinator is told to finish setting up a finished ceremony`() = runBlocking { + val theirs = session("theirs", DkgRitualStage.COMPLETE).copy(coordinatorPublicKey = ada) + val ours = session("ours", DkgRitualStage.COMPLETE) + + val loaded = load( + sessions = listOf(theirs, ours), + roomAwaitingCreation = "theRoomItDerives" + ) + + assertEquals(listOf("ours"), loaded.waitingForYou.map { it.session.id }) + assertEquals("Ada", loaded.rest.single().coordinatorName) + assertEquals("You", loaded.waitingForYou.single().coordinatorName) + } + + /** + * An abandoned ceremony is asked nothing at all. There is no step left to + * approve and no key to build a room from, so a row that claimed otherwise + * would send a member to a screen with nothing on it. + */ + @Test + fun `an abandoned ceremony is never waiting on anybody`() = runBlocking { + val loaded = load( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Both answers say yes, and both have to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + roomAwaitingCreation = "theRoomItDerives" + ) + + assertTrue(loaded.waitingForYou.isEmpty()) + + val row = loaded.rest.single() + assertNull(row.pendingApproval) + assertNull(row.roomAwaitingCreation) + } + + private suspend fun load( + sessions: List, + pending: Map = emptyMap(), + roomAwaitingCreation: String? = null, + ): DkgRitualListUIState.Loaded { + val viewModel = DkgRitualListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = me, + initialDkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + + override suspend fun roomAwaitingCreation(session: DkgSession): String? = + roomAwaitingCreation + } + ) + + viewModel.initiate() + + return withTimeout(5_000) { + viewModel.dkgRitualListUIState.first { it is DkgRitualListUIState.Loaded } + } as DkgRitualListUIState.Loaded + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me to null, ada to "Ada").map { (publicKey, name) -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = name?.let { + Profile( + publicKey = publicKey, + displayName = it, + nostrEventId = "ee".repeat(32), + ) + }, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt new file mode 100644 index 00000000..84815488 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt @@ -0,0 +1,322 @@ +package press.mantra.compose.ui.view.model + +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.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A parent's room holds every one of its subgroups' signings, and the ceremony + * screen has to pick out the one it is showing. + * + * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the + * rungs after the ceremony run there too: the birth certificate is the parent's + * signature, and the key state is signed over the parent's room because the + * subgroup's own room does not exist yet. So a parent making a second subgroup + * has two of each in one room, told apart by nothing but the ceremony they + * belong to. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualSecondSubgroupJvmTest { + private val parentChatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + // Real points, in the compressed form `SharedKeyDerivation` reads: it derives + // a room id off the curve, and a made-up 32 bytes is not on it. + private val firstKey = "02" + KeyPair().pubKey.toHexKey() + private val secondKey = "02" + KeyPair().pubKey.toHexKey() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + // Newest first, the way the query returns them. + certificateSigning("forSecond", subgroupOf(secondKey)), + certificateSigning("forFirst", subgroupOf(firstKey)), + ), + until = { it.certificateSession != null } + ) + + assertEquals( + "forFirst", + loaded.certificateSession?.id, + "the screen for one subgroup is following another subgroup's certificate" + ) + } + + @Test + fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + keyStateSigning("forSecond", dkgSessionId = "secondCeremony"), + keyStateSigning("forFirst", dkgSessionId = "firstCeremony"), + ), + until = { it.keyStateSession != null } + ) + + assertEquals( + "forFirst", + loaded.keyStateSession?.id, + "the screen for one ceremony is following another ceremony's key state" + ) + } + + /** + * The damaging half, and the reason this is a block rather than a cosmetic + * mix-up. Both rungs disable their button while a signing of that kind is + * running and un-failed, and a *completed* signing is un-failed forever -- + * so by kind alone, a parent that had certified one subgroup could never be + * asked to certify another, and the rung sat there disabled with no way out. + */ + @Test + fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking { + // Driven in two emissions rather than one, so the assertion is about a + // watcher that demonstrably ran. Asserting "still null" against a single + // emission passes before the watcher has started, which is how a version + // of this test passed against the bug it was written for. + val signingSessions = MutableStateFlow( + listOf( + certificateSigning("mine", subgroupOf(firstKey)), + keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"), + ) + ) + + val viewModel = viewModel(signingSessions) + viewModel.initiate() + + awaitLoaded(viewModel) { + it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState" + } + + // Now the room holds only a sibling's, both finished -- which is every + // parent that has already made one subgroup and is making its second. + signingSessions.value = listOf( + certificateSigning( + "siblingCertificate", + subgroupOf(secondKey), + stage = FrostSigningStage.COMPLETE + ), + // A key state naming another ceremony: the sibling's, or this parent's + // own from an adoption, which is signed in this room too. + keyStateSigning( + "siblingKeyState", + dkgSessionId = "secondCeremony", + stage = FrostSigningStage.COMPLETE + ), + ) + + val loaded = awaitLoaded(viewModel) { + it.certificateSession == null && it.keyStateSession == null + } + + // Both rungs gate on these being null or failed, and a completed signing + // is never failed -- so a sibling left in either of them shuts the rung + // for good. + assertNull(loaded.certificateSession) + assertNull(loaded.keyStateSession) + } + + private suspend fun load( + signingSessions: List, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded { + val viewModel = viewModel(MutableStateFlow(signingSessions)) + viewModel.initiate() + return awaitLoaded(viewModel, until) + } + + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + + /** The screen is always showing the FIRST subgroup's ceremony. */ + private fun viewModel( + signingSessions: MutableStateFlow> + ): DkgRitualViewModel { + val ceremony = DkgSession( + id = "firstCeremony", + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = DkgRitualStage.COMPLETE, + thresholdPublicKey = firstKey, + parentChatRoomId = parentChatRoomId, + subject = "Translators", + ) + + return DkgRitualViewModel( + chatRoomId = parentChatRoomId, + activeUserPublicKey = me, + dkgSessionId = ceremony.id, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null + + override fun observeSessionById(sessionId: String): Flow = + flowOf(ceremony) + + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(ceremony) + + override fun observeSigningSessions( + chatRoomId: String + ): Flow> = signingSessions + } + ) + } + + /** The room id a ceremony's key derives, which is what a certificate names. */ + private fun subgroupOf(thresholdPublicKey: HexKey): String = + press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private fun certificateSigning( + id: String, + subgroupChatRoomId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = firstKey, + adminPublicKeys = listOf(me, peer), + name = "Translators", + ), + content = subgroupChatRoomId, + sig = "ff".repeat(64), + ) + ) + + private fun keyStateSigning( + id: String, + dkgSessionId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = subgroupOf(firstKey), + dkgSessionId = dkgSessionId, + ), + content = firstKey, + sig = "ff".repeat(64), + ) + ) + + private fun signing( + id: String, + event: Event, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + stage = stage, + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "firstCeremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = id, + itemIndex = 0, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "55".repeat(32), + ) + ), + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = parentChatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = "state", + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = parentChatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt new file mode 100644 index 00000000..e811acb0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt @@ -0,0 +1,260 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +/** + * The two ways the key-ceremony screen came to offer to start the ceremony it + * was already showing. + * + * "Start key ceremony" is the whole no-ceremony screen -- the explainer, the + * quorum picker and the button -- so anything that leaves the state saying + * there is no ceremony puts that offer in front of a member watching one run. + * Both of these did, and both are invisible to a test that only asks the view + * model what it settles on: the state was right before and right after, and + * wrong in between for long enough to read and to tap. + * + * Neither is about ChillDKG. They are about one `DkgRitualUIState` being + * assembled by seven flows at once, which is why they are tested here rather + * than against the ritual. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualStartOfferJvmTest { + private val chatRoomId = "a1b2c3d4".repeat(8) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * The first loaded state already knows the room is running a ceremony. + * + * The screen used to publish a bare `Loaded` and then wait for the rows, + * and a `Loaded` with no session and no ceremony *is* the offer to start + * one. On a cold open -- the only time the queries are slow -- that is + * exactly when it was shown. + */ + @Test + fun `the offer to start is not published while the rows are still being read`() = runBlocking { + val session = session(DkgRitualStage.COLLECTING_HOST_KEYS) + + val viewModel = viewModel( + object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + // Slow on purpose: a Room query answers in microseconds and + // would hide the window rather than prove it is gone. + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flow { + delay(50) + emit(session) + } + } + ) + + viewModel.initiate() + + val firstLoaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } + } as DkgRitualUIState.Loaded + + assertEquals(session.id, firstLoaded.session?.id, "the first loaded state missed the ceremony") + assertFalse( + viewModel.canStartRitual(firstLoaded), + "offered to start a ceremony on a room already running one" + ) + } + + /** + * A write that suspends between reading the state and writing it back does + * not carry the stale copy over somebody else's change. + * + * Every flow folding into this state used to read it, build a `copy` and + * assign -- and the session watcher asks the database what this member owes + * in the middle of that. Whoever wrote during that pause had their change + * thrown away by the assignment that followed. The same pause in the other + * direction is what put `session` back to null and offered to start the + * ceremony on screen; this is the half of it that can be forced to happen + * rather than raced into. + * + * The pair used here is the session watcher against the room watcher, which + * are the two that are genuinely concurrent: both are collecting before + * either has written, and neither waits on the other. `roomHasCeremony` is + * the field they fight over, and it is the one that decides the offer. + */ + @Test + fun `a change written during a suspension survives the write that follows it`() = runBlocking { + val live = session(DkgRitualStage.COLLECTING_ROUND_1) + val abandoned = session(DkgRitualStage.FAILED) + + val roomSessions = MutableStateFlow(live) + + // Opened once the test has had the room watcher write. The seeding call + // runs before anything else is collecting, so it is let through; the + // session watcher's call is the one that waits, and `parked` says when + // it is in there. + val gate = CompletableDeferred() + val parked = CompletableDeferred() + var calls = 0 + + val viewModel = viewModel( + object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = roomSessions + + override fun observeSessionById(sessionId: String): Flow = + flowOf(live) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? { + calls += 1 + if (calls == 1) return null + + parked.complete(Unit) + gate.await() + return DkgApprovalStep.ROUND_1 + } + } + ) + + viewModel.initiate() + + awaitLoaded(viewModel) { it.roomHasCeremony } + + // The session watcher is now inside `pendingApproval`, holding -- in the + // shape this used to be written in -- a copy of the state as it was + // before the next two lines. + withTimeout(5_000) { parked.await() } + roomSessions.value = abandoned + awaitLoaded(viewModel) { !it.roomHasCeremony } + + gate.complete(Unit) + + val settled = awaitLoaded(viewModel) { + it.pendingApproval == DkgApprovalStep.ROUND_1 + } + + assertFalse( + settled.roomHasCeremony, + "the session watcher wrote back a copy taken before the room watcher's change" + ) + assertEquals(live.id, settled.session?.id) + } + + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + + private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = me, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = dkgRepository + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(stage: DkgRitualStage) = DkgSession( + id = "ceremony", + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) + + private fun keyStateSigningSession() = LocalFrostSigningSession( + session = FrostSigningSession( + id = "signing", + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "ceremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = "signing", + itemIndex = 0, + unsignedEventJson = """{"id":"${"33".repeat(32)}","pubkey":"$me",""" + + """"created_at":0,"kind":30326,"tags":[],"content":"","sig":"${"44".repeat(64)}"}""", + eventId = "33".repeat(32), + nonceRandom = "55".repeat(32), + ) + ), + ) +} diff --git a/docs/README.md b/docs/README.md index 15ddd053..5dbb992a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 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 | | [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived | +| [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the certificate a group can truthfully sign about itself | | [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [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 | | [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them | @@ -39,7 +40,11 @@ and the one thing they cannot. The jvm-target note is unrelated to all of them: it is a build and packaging story. The subgroups note is a phased plan that has been built; it assumes both shared-key notes and reads as the ceremony's second half — what a group does -once it has a key, and what it can say about a group that does not yet. +once it has a key, and what it can say about a group that does not yet. The +multiple-parents note is a phased plan that has been built, and reads only after +it: it takes the one assumption that note never had to state — that a lineage is +settled at founding — and finds that dropping it costs less than founding a +subgroup does, because no ceremony is involved. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. diff --git a/docs/project-feature.md b/docs/project-feature.md index 5e73a85e..8c06efc8 100644 --- a/docs/project-feature.md +++ b/docs/project-feature.md @@ -14,6 +14,7 @@ it belongs to: | the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` | | three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot | | around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub | +| schema 21 | schema 22 | `mantra` took 21 for the subgroup parentage table while this branch was open; Phase 0 said to check for exactly this and checked the sibling fork rather than the base | | the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch | The one thing found while building that the plan did not anticipate: a project @@ -388,6 +389,13 @@ No code. Settle, in this document: takes 21; check the Curare clone before claiming it, because two trees that both call their next migration 21 is a merge nobody can fix afterwards. + **This is the one decision the build got wrong, and it checked the wrong + tree.** Projects took 21 on their own branch while `mantra` took 21 for the + subgroup parentage table (`eba97333`), which is a manual `MIGRATION_20_21` + rather than an auto-migration. The collision cost nothing because neither had + shipped — projects renumbered to **22** on the way in — but the lesson is that + the sibling fork was not the only tree to check. Check the base branch. + ## Phase 1 — three events and their tags New, under `press.mantra.compose.nostr.nip30303`: @@ -559,7 +567,7 @@ rather than four events that share a session. regression test against anyone folding them back together; - a `ProjectEvent` case asserting `build` rejects an empty owner set. -## Phase 2 — three rows, and schema 20 → 21 +## Phase 2 — three rows, and schema 21 → 22 New, under `press.mantra.compose.database.model`: @@ -651,7 +659,7 @@ and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a mechanism being retired, to serve rows that cannot exist. **Schema.** Three tables, nothing existing changes shape, so this is an -`AutoMigration(from = 20, to = 21)` entry with a comment in the style of the other +`AutoMigration(from = 21, to = 22)` entry with a comment in the style of the other nineteen, plus three entities in `@Database`, three `abstract fun`s on `MantraDatabase`, and three DAOs. diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md new file mode 100644 index 00000000..e3efe414 --- /dev/null +++ b/docs/subgroups-multiple-parents.md @@ -0,0 +1,803 @@ +# Adding a parent to a group that already exists + +How a group gains a second parent after it was founded. + +**Built**, five phases, one commit each. The phases below are kept as written +because they are the reasoning, and the code reads better against the argument it +came from than against a summary of itself. Where the implementation chose +differently the section says so, and it did so eight times worth reading: + +| what the note said | what it turned out to be | +|---|---| +| the 30329 arm "gains a second branch" | a pure `SubgroupManager.classify` in front of it. The arm cannot tell its two callers apart and the decision wanted testing without a database, which `certifies` makes possible -- it checks a signature against a room id and needs no lookup | +| nothing about a group certifying itself | **a real gap, found by a test.** A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent. The self-parent guard began inside the offer branch, where it never ran: the child branch catches such a certificate first and reads it as an ordinary child, which would list a group as its own subgroup. It predates this work -- `subgroupsOf` would have shown it | +| drop `ChatRoom.parentChatRoomId` | and the `parentChatRoomId` parameter threaded through four files to reach the write that was its only writer. The note counted the column and missed the thread | +| `stateFrom` returns a state and a lineage | `LocalGroupKeyState`, a Room read model that is also a plain data class -- so the pure verifier builds one with no database near it, and `@Relation` serves the DAO | +| the certificate's roster is the founding roster, frozen | true, and silent about adoption, where there is no founding. The adopting group's admins are asked about a group that *exists*, so the roster and name are its **current** ones -- still frozen the moment they are signed, still never read as live | +| the summary says which parents are being dropped | not done, and it cannot be: a summary is pure and a disavowal is a difference against the row this device holds. It belongs to the screen, which has the room, and there is a comment where somebody will look | +| `EmptyState` takes a supporting line | it does not. The reason folded into the message, which is the whole content of that absence anyway | +| `inComparableGroups` moved to the key extensions | in the fork's tree, not this one, where it is still private to `ChatRoomDetailScreen`. A second copy to add three spaces to an id is not worth having | + +Two things the note got right that are worth keeping if any of this is rewritten. +Emitting the parentage tags **pairwise** rather than as two runs, which is the one +decision here made entirely for builds that do not exist yet -- and which has a +test asserting the tag order, not just the round trip. And `replace` rewriting a +lineage rather than merging it, which is what makes a disavowal expressible at all +and is the reason that method is a transaction. + +**Not reachable yet:** `proposeDisavowal` is built and tested and has no button. +Dropping a parent is a destructive-looking act that wants a confirmation step, and +it belongs in its own change. + +Written against the code as it stands at `e15d31b8`. + +Read [subgroups.md](./subgroups.md) first. This extends it and contradicts it in +one place — the assumption, never stated because it never had to be, that a +lineage is a single link settled at founding. + +Written against the code as it stands at `0be31803` — that is, **after** +`subgroups.md`'s Phase 10 moved the child's ceremony into the parent's Marmot room. +That move removed two of the costs an earlier draft of this note claimed adoption +would avoid and reshaped a third, so the case is made here on what is left. It also +makes founding with two parents *harder* — see the +[rejected alternatives](#considered-and-rejected). + +## The short answer + +**Adoption is two FROST sessions and no ceremony.** + +``` + 1. birth certificate, in the new parent -> P2's quorum signs C + 2. key state, in C itself -> C's quorum re-announces, carrying + every certificate it accepts +``` + +That is the whole flow. Compare it with founding, which is four steps: + +| founding a subgroup | adopting an existing group | +|---|---| +| 1. ChillDKG among the child's admins | — the key already exists | +| 2. birth certificate, in the parent | **the same step, unchanged** | +| 3. key state, in the parent's room where the ceremony ran | **the same event, in the child's own Marmot room** | +| 4. create the Marmot room | — the room already exists | + +Losing step 1 is the point. `subgroups.md` names it as the single biggest +practical cost of a subgroup — *"a ChillDKG cannot finish without all `n`… a +member who never approves stalls the subgroup at step 1 indefinitely"* — and it +is also the last thing standing between a lineage and being an ordinary edit. + +Phase 10 has already paid down what it used to drag in: the NIP-17 sibling room and +the member-derived id are gone, and the collision guard is now +`DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather +than an address that could only be occupied once. So this note claims none of those as +savings — only the ceremony and the room creation, which is enough. + +**The whole of `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, +`SelectSubgroupAdminsScreen` and the ritual screen's rung is untouched by this +design** — and Phase 10 makes that a stronger statement than it was, because those +files have just absorbed a large change that adoption does not have to follow. + +Three things in the existing code make step 2 work as it stands: + +- `GroupKeyStateManager.propose` already permits a room to propose its own key + state: `check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId)`, + and for an existing child the first branch holds. Nothing forbids + re-announcement today; it is simply never done. +- `FrostSigningManager.signingPath` resolves correctly for it. Its first candidate + is `GroupKeyStateManager.keyStateFor(chatRoomId)?.pathIndices()`, which for the + child is `m/9420/0/0`, and `marmotGroupId(K, path) == chatRoomId` self-checks. + The one un-self-checkable case is **not** reached. Phase 10 widened it from + `mlsGroupState == null` to `isCeremonyRoom && (mlsGroupState == null || + isSubgroupCeremony)`, so it now admits the parent's room as well as a NIP-17 one — + and adoption enters neither arm, because the child's own room is neither the + ceremony's room nor its claimed parent. +- `GroupKeyStateDao.replace` keeps the newest state per room by `announcedAt`, and + `announcedAt` comes off the signed event. So a re-announcement replaces the old + lineage cleanly, and an out-of-order arrival is dropped rather than applied. + +## A parent link is two signatures, one from each side + +Worth stating plainly, because the founding flow bundles them and so hides it. + +The certificate is the **parent's** consent: P's quorum saying "C is our child". +The parentage tags on the key state are the **child's** consent: C's quorum saying +"we accept P". Neither alone is a link. A parent cannot adopt a group that has not +agreed, and a group cannot claim a parent that has not signed — which is exactly +why `GroupKeyStateManager.parentageOf` drops a state whose certificate does not +check out, and why `SubgroupManager.proposeBirthCertificate` runs in the parent's +room rather than the child's. + +At founding the two are bundled because the key state has to be signed anyway: it +is the founding fact, and the parentage rides along. For an existing group there +is no founding fact left to settle, so **step 2 is a key state signed purely as a +carrier for the child's consent.** That is the one slightly odd thing about this +design and it is worth doing anyway — see [why not a separate adoption +kind](#considered-and-rejected). + +Once that is seen, the whole thing collapses to one sentence: + +> A group's lineage is a set of (parent, certificate) pairs carried on its key +> state. Adding a parent is: that parent certifies, then the child re-announces +> its key state with the pair added. Founding a subgroup is the case where the +> set has one member and the key state was being signed anyway. + +## The invariant, and how it differs from founding-time multi-parent + +If two parents certified a child *at founding*, both certificates would be signed +at the same moment and could name each other — so a reader could demand that every +certificate agree on the whole parent set, and a coordinator could not staple two +unrelated certificates into a joint child. + +**That check is not available here, and cannot be made available.** P1 certified C +before P2 existed as a possibility. An earlier signature cannot name a later +co-parent, and asking P1 to re-certify every time a sibling appears makes every +adoption cost a quorum in every existing parent. + +So the invariant has to be weaker, and the honest version is: + +> **The child's key state is the sole authority on the lineage set. Each +> certificate is checked individually — this parent signed for this child — and +> the set is whatever the child's quorum most recently declared.** + +Which means a parent gets a veto on being *added* and no veto on *who else is in +the set*. That is not a compromise; it is the correct answer: + +- Nothing P1 signed becomes false. P1 said "C is our child". C being P2's child + as well does not contradict it. +- Exclusivity was never expressible and could not be enforced anyway. P2 can + certify C whether P1 likes it or not — a certificate is a unilateral statement + about a public room id. What P2 cannot do is make C *accept* it. +- **The child's consent is the whole gate**, and it should be, because the child + is the one that acquires an obligation. + +`parentageOf` therefore grows from four checks to six, and the two new ones are +small: + +1. **All or none**, per pair. (Today's rule.) +2. Each certificate parses. (Today's.) +3. Each `certifies(certificate, child, itsParent)`. **`certifies` does not + change** — it is six questions about one signer and one child and it stays + that, so `SubgroupBirthCertificateEventTest`'s existing seven cases keep + passing untouched. +4. Each certificate's `subgroup_key` is the state's own threshold key. (Today's.) +5. **No duplicate parents.** A set with a repeat in it is not a set. +6. Any failure drops the whole state. (Today's.) + +There is no `co_parent` tag and no cross-certificate agreement check. Both were +needed only by the founding-time design, and both are gone. + +### The footgun this creates + +A key state is replaceable and the newest wins **wholesale**. So re-announcing to +add P2 must re-emit P1's pair as well, or P1 is silently dropped. + +That is a bad API to leave open. `propose` should take the *resulting* set and +read the current one off the row, so that "add P2" is expressed as an addition +rather than as a full replacement a caller has to remember to assemble. The same +call then expresses removal, which is the next section. + +## What this also unlocks + +Two things fall out of the same mechanism, and neither needs extra work. + +**Any existing robust group can become a subgroup.** Nothing in the two steps +requires the child to already have a parent. A top-level group with a key state +gets adopted exactly the same way, with the set going from empty to one. That is +arguably worth more than multi-parent itself, and it is the same code path. + +**A child can drop a parent.** Re-announce with the pair removed. This partly +answers `subgroups.md`'s *"Removing a subgroup is not a thing"* — and only partly, +so the limit is worth being precise about: + +- The certificate is forever. Nothing revokes a signature and nothing should + pretend to. +- But a link is two signatures, and the child's half is replaceable. So the child + can stop claiming a parent, and every device holding its key state will stop + believing the link. +- The parent's own list does **not** follow, because `SubgroupManager.subgroupsOf` + reads the parent's certificates, not the child's key state. P1 keeps listing C. +- That is fixable in the display without any new protocol: `Subgroup` already + carries a `keyState` field, populated where the device holds the child's state. + A parent device that also holds C's key state can see that C no longer names it + and say so. A parent device that does not hold it cannot, and must not guess. + +## The wire + +### The key state carries pairs, in matched order + +No new tag names. `parent_group` and `birth_certificate` repeat: + +``` +["parent_group", ] +["birth_certificate", ] +["parent_group", ] +["birth_certificate", ] +``` + +`SubgroupParentTag.parse` and `SubgroupBirthCertificateTag.parse` already work +per-tag; the readers change from `firstNotNullOfOrNull` to `mapNotNull`, and +`assembleTags` takes a list instead of a nullable single. That is the entire wire +change. + +**The order is load-bearing, and only for old builds.** +`GroupKeyStateEvent.parseParentChatRoomId` and `parseBirthCertificate` take the +*first* of each, so a build predating this pairs `parent_group[0]` with +`birth_certificate[0]` and runs `certifies` on them: + +- emitted **pairwise** — P1, P1's cert, P2, P2's cert — it pairs P1 with P1's + certificate, it verifies, and the device files a true but partial lineage. + Correct, just less. +- emitted **grouped** — both parents, then both certificates — it is coherent only + while both lists happen to be in the same order, and silently wrong the moment + they are not. Then `certifies` fails and the old build **drops the whole state**, + losing the row that says which ceremony's share signs for that room. Recoverable + through `completedKey`'s rederivation fallback, and only for default-path rooms. + +So: emit pairwise, sorted by parent id so every device produces the same bytes. +Free, and the difference between graceful degradation and an old device quietly +losing its key state. + +### The certificate does not change at all + +Kind 30329 keeps its tags, its `certifies`, and its meaning. A certificate signed +for a founding subgroup and one signed for an adoption are the same event — which +is right, because they say the same thing. + +## The schema + +Much smaller than the founding-time version, because `DkgSession` is not involved. + +**`GroupKeyState`: one-to-many.** The two columns `parentChatRoomId` and +`birthCertificateJson` become a table: + +```kotlin +@Entity(primaryKeys = ["chatRoomId", "parentChatRoomId"], foreignKeys = [ + ForeignKey(entity = GroupKeyState::class, parentColumns = ["chatRoomId"], + childColumns = ["chatRoomId"], onDelete = ForeignKey.CASCADE) +]) +data class GroupKeyStateParent( + val chatRoomId: String, + val parentChatRoomId: HexKey, + val birthCertificateJson: String, +) +``` + +The composite primary key *is* check (5) — one certificate per (child, parent), +enforced by the store rather than by a reader remembering. The cascade is onto +`GroupKeyState`, never onto `ChatRoom`, so `subgroups.md`'s argument against a +self-referential cascade stands untouched: the parent side stays a plain column +that routinely names a room this device does not have. + +The cost is that filing a state stops being one write. `GroupKeyStateDao.replace` +has newest-wins logic and would have to become a `@Transaction` that rewrites the +parentage rows with the state, or a dropped parent survives the re-announcement +that dropped it — which is exactly the operation this design adds. + +**`DkgSession`: untouched.** Adoption opens no ceremony, so it needs no session, no +discriminator and no migration there. Phase 10 has just added two columns to that +table — `participantPublicKeys`, a sorted comma-joined set matched whole by +`getLatestSubgroupSessionFor`, and `subject` — which is worth knowing for two +reasons: it is the precedent for the canonical-string shape, and it is the change +adoption is glad not to be entangled with. + +**`ChatRoom.parentChatRoomId`: drop it.** It is write-only in production (see +[found while reading](#found-while-reading)), and leaving it holding one of +several parents makes it a cache that is wrong rather than merely unused. + +Schema **20 → 21**, and the number moves under this note faster than the design does: +19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`, and 20 was the +several-profiles pull's, putting an owning identity on the queues. So take the version +from `MantraDatabase` when this is built rather than from here. One new table, one +dropped column, and the existing rows carried across as a single parentage row each. +Not an `AutoMigration`; schema 9→10 already has the shape to copy. + +### The zero-migration alternative, and why not + +The parentage columns are already a *cache*: `stateFrom` derives them from the +signed 30326 event, and `signedStateFor` can re-read the newest such event for any +room at any time. So the lineage could be a derived read — parse the signed event, +run the checks, return the set — with no schema change whatsoever. + +Worth knowing it is available, and worth not taking. `signingPath` reads the +`GroupKeyState` row on **every signature**, so the row stays either way; a design +where the path comes off the row and the lineage comes off the event is two +sources for one statement, which is the thing `subgroups.md` spends its appendix +refusing. Cache the whole state or cache none of it. + +## Who can actually do this + +The one requirement that does not soften, and it should be stated before the UI is +designed around it. + +**Step 1** — proposing the certificate in P2 — needs a P2 admin holding a share of +P2's key, *who also knows the child's threshold key*. The certificate's tags carry +`subgroup_key`, and without it `certifies` fails at question 4. The child's +threshold key lives on the child's `GroupKeyState`, which only the child's members +hold. So the proposer is in both groups. + +**Step 2** — proposing the key state in C — needs a member of C holding a share of +C's key, *who has P2's certificate*. Certificates are filed as `GroupSignedEvent` +rows by `recordSignedEvents`, keyed on the session's room, so only P2's members +have one. So this proposer is also in both groups. + +They need not be the same person, and their capabilities differ — step 1 needs +admin rights in P2, step 2 needs a share of C's key. But **adoption requires at +least one person in both groups**, and there is no way around it: the certificate +has to cross a boundary that nothing in the app crosses. + +### One human, two profiles, and an unscoped read + +Since several profiles landed, "one person in both groups" has a second reading, and +it is the one that will happen first: the same human as a P2 admin under one profile +and a holder of C's share under another. + +That satisfies both steps and needs no single key in both groups, which is a real +loosening. The mechanism is the part to be careful about. `ChatRoom` is keyed by **the +group id alone** — `679d5249`'s message says so, and the MLS state, the DKG sessions +and the FROST shares all hang off the room — so the two rooms are two rows with +different ids and different `userPublicKey` holders in **one** database. P2's +certificate is a `GroupSignedEvent` hanging off P2's room, and +`groupSignedEventDao().getById(id)` is not scoped by identity, so the other profile's +code can read it. + +So it works, and it works by reading across a boundary the app has already had to +defend once: `679d5249` exists because the Welcome branch could not tell a redelivered +Welcome from one for a room another profile holds, and told nobody. + +**And the key check this design leans on does not draw that boundary either.** +`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` is +`WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL` with no identity in +it, and `getLatestOwnSessionForChatRoom` is scoped only by room and +`parentChatRoomId IS NULL`. So `FrostSigningManager.canSign` answers a **device-wide** +question — this device holds a share — where every sentence in this note that says +"this device holds no share of P2's key" means the profile in the chair. + +That is pre-existing and device-local: both profiles' shares are already on one disk, +owned by one person, so nothing crosses a user boundary. What it costs is profile +isolation, which is the point of having profiles at all — and it is why **a step-2 +proposer must take the certificate from a source scoped to the profile that is +acting**, rather than from whatever the database happens to hold. Otherwise adoption +is a feature that silently works better when you own both sides, which is the shape of +a leak rather than of a convenience. Worth its own look, not this note's to fix. + +### Still weaker than founding-time multi-parent + +Where one coordinator had to administer both parents *and* run a ceremony. And it is a +realistic requirement — a group being adopted by another group almost certainly has +someone +in both. + +What would remove it is a cross-group request: a way for C to publish "we would +like P2 to adopt us, here is our key" and for P2's admins to act on it. That is a +real feature with its own consent questions, and it should not be smuggled in as +a helper. + +### The certificate rides on the key state, and needs no storage of its own + +The obvious-looking alternative — file P2's certificate on the child's devices +when it arrives — is closed, and the closure is load-bearing: +`GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with +no P2 room row **cannot** store a P2-signed event at all. + +It does not need to. `GroupKeyStateManager.propose` takes the certificate as an +`Event`, not as a row, and once the child's quorum signs the re-announced state +the certificate travels inside it — verified standalone by `stateFrom` on every +child device, exactly as at founding. The proposer needs the event; nobody else +does. + +### The one channel that must not be used, and it is already built + +There is now a path that would carry a certificate out of the encryption, and it is +dormant rather than absent. `BroadcastGroupSignedEventViewModel` sends one +group-signed event to relays a member picks, and its own comment states the property +that makes it dangerous here: *"the signature is the group's, so any member holding +the event can send it and no share of the key is needed."* It is **not** restricted by +kind — "the seed is the same for every event of every kind" — and nothing opens it +today only because the rows that carried the button went with the curated lists. +`BroadcastGroupSignedEventRoute` is still registered and `BroadcastButton` still +built, so, in that file's words, wiring a button in is all it takes. + +A certificate is the worst event in the app to send. It carries the parent-child +lineage, the child's threshold key, and the founding admin roster in its `p` tags — +a membership list — and `subgroups.md` places kind 30329 "in the private 303xx range +with everything else that never leaves an encryption". +`SubgroupBirthCertificateEvent`'s own comment leans on that: because it only ever +exists inside an encryption a relay cannot open, "the addressable semantics of the +3xxxx range never fire". Published, they fire — it is `d`-tagged on the child's room +id, so relays would treat certificates as replacements of one another, which is a +thing this design settles locally and on purpose. + +**So this design carries a negative rule: the subgroups list and the parent cards must +never grow a broadcast button**, and a certificate must not be reachable from whatever +screen a future "broadcast this" sweep walks. It is also why relays are not the answer +to the overlap requirement above, tempting as it looks: a P2 member could publish the +certificate and a C member could fetch and verify it — `certifies` needs no lookup — +but the price is publishing the lineage and the roster to anyone, to save one person +being in two groups. + +## Choosing the parent + +Worth its own section, because "pick a group" is not what the picker can offer and +designing the screen around the wrong list would be the easy mistake. + +### The list is the selector's own rooms, and cannot be anything else + +A certificate is proposed **inside** the adopting group, by +`FrostSigningManager.proposeSigning` in that room. So the candidate list is not "the +groups that exist" or "the groups C's admins can name" — it is the rooms **the active +profile** can act in. Not the device's: `getChatRoomListByUserPublicKey` is keyed by +identity, and since several profiles landed a device can hold rooms it must not offer +here, because the key that would have to sign is not the one in the chair. + +``` +getChatRoomListByUserPublicKey(user) + filter { it is a Marmot room whose MarmotGroupData reads } + filter { adminPubkeys.contains(userPublicKey) } + filter { FrostSigningManager.canSign(database, it.id) } + filter { it.id != C } + filter { it.id !in C's current parents } + filter { it.id is not a descendant of C } +``` + +The first three filters are `SubgroupManager.refuseSubgroup`'s existing checks with +the target moved from "the parent of a new subgroup" to "the adopting group", and the +admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for +the reason that function already gives: the flag is written only by +`processGroupMembershipChanges` on an arriving commit, so it is null for everybody on +a room this device created. + +**A group cannot be offered as a parent by somebody who is not in it.** That is the +overlap requirement from [who can actually do this](#who-can-actually-do-this) showing +up as a list length. If nobody in C administers P2, P2 simply does not appear, and the +remedy is a person rather than a button. + +### Cost, and where it goes + +`toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`, +once per candidate room. `canSign` is cheap for exactly the rooms that pass — +`completedKey`'s first branch is a key-state lookup and a session lookup by id, and +its expensive fallback (`getKeyHoldingSessions` with a `marmotGroupId` derivation per +session) only fires for rooms with no key state, which fail the filter anyway. Its last +fallback is now `getLatestOwnSessionForChatRoom` — the same query scoped to +`parentChatRoomId IS NULL`, so that a parent hosting its child's ceremony cannot +resolve the child's key as its own. + +So the filter is one MLS decode per room and two indexed reads. Fine at twenty rooms +and worth building the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` +already builds its prefetch: off the main thread, filling in as it resolves, rather +than blocking the screen on the whole list. + +### The selection is an offer, not a decision + +The ordering is forced — the key state carries the certificate, so the certificate is +signed first — and that has a consequence worth stating in the UI's own words: + +> **P2's quorum commits first. C's quorum decides second.** + +Whoever picks P2 is not choosing on C's behalf. They are asking P2 to make an offer, +and C's admins answer it at step 2 by signing the re-announced key state or not. The +real decision point for C is the key-state proposal, which is why +[the `ProposedEvent` gap](#consent-on-both-sides) is the most important small change +in this design. + +It also means P2's quorum can be spent on an offer that is declined. That is not new — +founding has the same ordering, and `proposeBirthCertificate` runs before the child's +quorum has signed anything either — but at founding the child's admins have at least +*seen* the intent, because `DkgRitualEvents`' `parent_group` tag rides on the ceremony +proposal and the ritual screen says which group it is for. Adoption has no ceremony +and so no such hint. + +### So C's admins need telling, and the arm to tell them already exists + +Without one, the first thing an admin of C hears about the whole business is a +proposal asking for their signature. + +`ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch. Today it calls +`SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` — the +certificate's parent must be the room it arrived in — and drops anything else. A +certificate that instead names *this* room as its subject, and verifies against the +parent it names, is the arriving offer: + +| the certificate | the line | +|---|---| +| signed by this room, about another | "The group made #Translators a subgroup…" (today's) | +| about this room, signed by another | "#Ekklesia has offered to adopt this group" | + +Both branches are fully verified and neither needs a lookup: `certifies` checks the +signature against a room id, and a room id is a public key. Nothing is stored — the +`GroupSignedEvent` foreign key onto `ChatRoom` forbids it, and nothing needs it, +because the certificate reaches C's devices for real inside the key state at step 2. +The line is notice, not evidence, and should read as an offer rather than as a fact +about C's lineage. + +Delivering the certificate into C is then one act by the member who is in both, and it +is the same act that gives the step-2 proposer the event they need. + +### Which screen it starts from + +Two defensible entry points, and they produce an identical certificate: + +- **C's detail screen**, beside the parent card: *"add a parent group"*. The child is + the one acquiring the relationship, its quorum is the one that ultimately decides, + and it mirrors the parent screen's existing *"add subgroup"*. +- **P2's detail screen**, beside *"add subgroup"*: *"adopt an existing group"*. This is + where the proposal actually lands, and where P2's admins would look for what their + group has taken on. + +C's screen is the better default, for the mirroring and because the intent usually +originates with the group looking for a parent. P2's is worth adding later; the guards +are the same set read in the other direction, and the candidate list becomes "Marmot +rooms I hold a key state for", since that is where `subgroup_key` comes from. + +## Consent, on both sides + +The parent's side already works. A P2 admin sees `ProposedEvent`'s 30329 summary — +the name and the admin count — and their device checks +`marmotGroupId(subgroup_key, frost_path) == content` before they agree. Unchanged. + +**The child's side does not, and this is the gap that has to close.** +`ProposedEvent`'s 30326 arm reads: + +``` +label: "This group's shared key" +detail: "m/9420/0/0 · a1b2c3d4e5f6" +``` + +A child admin approving an adoption would be shown the path and a ceremony id, and +nothing whatever about the fact that they are accepting a parent. The proposal is +their only chance to check — after it, a quorum has spoken — so the arm has to +read the parentage off the tags it is already holding and say which groups are +being accepted, and which are being dropped relative to the current row. + +That is the single most important small change in this design. Everything else is +plumbing; this is the one place where a member is asked a question and is +currently not being told what it is. + +## Guards + +Two new refusal sets, both in the shape `SubgroupManager.refuseSubgroup` +already has: checked before the button is offered, and again in the manager. + +**Before proposing a certificate in P2** — the first three are its existing checks, +retargeted from "the parent of a new subgroup" to "the adopting group": + +| refused | because | +|---|---| +| P2 is not a Marmot room with readable group data | no admin list to consult; the same reason the subgroup button is hidden on a NIP-17 room | +| this device is not an admin of P2 | a non-admin proposing the group's signature is a proposal the admins must decline by hand | +| this device holds no share of P2's key | `proposeSigningBatch` throws, and throwing at a button is not a UI | +| this device does not hold C's key state | there is no `subgroup_key` to certify, and a certificate without one fails `certifies` question 4 on every device including this one | +| C **is** P2 | a group is not its own parent | +| P2 is already among C's parents | that is a re-certification, not an adoption. `proposeBirthCertificate`'s `check(certificateFor(...) == null)` already refuses it; it should say so in those words | +| P2 is a descendant of C, as far as this device can see | a cycle. Bounded walk, advisory only — see below | + +**Before proposing the key state in C:** + +| refused | because | +|---|---| +| this device holds no share of C's key | same reason | +| any certificate in the proposed set fails `certifies` | already checked by `propose`; now per pair. Proposing a state this device would drop spends a quorum's attention on nothing | +| the set has a duplicate parent | check (5), at the door rather than only on read | +| the set is unchanged from the current row | nothing to agree | + +**"This device" in both tables means the profile in the chair**, and `canSign` does +not currently tell the two apart — see +[one human, two profiles](#one-human-two-profiles-and-an-unscoped-read). Until it +does, these guards are honest about capability and silent about persona. + +## New failure modes + +**A lineage is a DAG now, so a walk can loop.** With one parent it is a tree and +`parentOf` returns a single id, so nothing was tempted to recurse. A cycle is +constructible — C's quorum certifies P1 using P1's public threshold key, and P1's +quorum re-announces its own key state accepting it, which this design is precisely +what makes possible. It takes both quorums cooperating in something pointless, but +any ancestor walk must be depth-bounded and visited-set guarded regardless, and +the guard above is only as good as what this device can see. `subgroups.md` already +notes depth 2 is untested; this makes it worth testing rather than merely noting. + +**A half-adopted state exists between the two steps** — P2 has certified and C has +not accepted — and every reader has to render it as an ordinary intermediate rather +than as an error. Note it is *invisible* from C's side unless the certificate has +been brought across, which means the child's screen cannot show "P2 has certified +you" for a member who is not in P2. + +**A re-announcement that forgets an existing parent drops it silently.** Addressed +by the API shape above, and worth a test that names it. + +## The UI + +Two rungs on the child's `ChatRoomDetailScreen`, beside the parent card. No new +screen, and no new approval UI at all — both proposals are ordinary +`FrostSigningEvents.PROPOSAL`s that `ProposalListScreen` and `FrostSigningScreen` +already show and approve. + +**"Add a parent group"** opens the picker described under [choosing the +parent](#choosing-the-parent) and proposes the certificate in whichever group is +chosen. The picker is a pushed screen, so it takes an `onNavigateBack` and puts +`NavigateBackButton` in its app bar's leading slot — `CLAUDE.md` gained that rule +while this note sat unbuilt, and the desktop target has neither a system back nor an +edge swipe to fall back on. Offered only where step 1's guards pass, hidden rather than disabled, in the +same way the shared-key entry is hidden on an MLS room — and empty for a member who +administers none of C's candidate parents, which is a fact about them rather than a +failure. + +**"Accept #P2 as a parent"** appears once a certificate this device can see has +been signed, and proposes the re-announced key state. Offered to any member of C +holding a share — not only to whoever started, for the same reason `subgroups.md` +gives for the ritual screen's rung. Its wording is the decision, not the mechanism: +C is accepting an offer, and the transcript line from the arriving certificate is +what it is accepting. + +**The parent card becomes up to N cards.** It reads +`ChatRoomDetailUIState.parentChatRoomId` ← `chatRepository.parentOf` ← +`GroupKeyState`, so it is a straight plural, and it keeps the rule it has: absent +rather than guessed where the state is not held. + +**`SubgroupManager.Subgroup` gains a "still claims us" signal**, off the `keyState` +field it already carries. Only where the device holds the child's state; silent +otherwise. + +## Tests + +- `SubgroupBirthCertificateEventTest` — **unchanged, and that is the assertion**. + `certifies` does not change, so all seven cases including the one whose job is + to fail if a roster check is added must keep passing with no edits. +- `GroupKeyStateTest` (commonTest, pure) — two parents both good; two parents with + one certificate missing → drop; the same parent twice → drop; a single-parent + state in today's shape still accepted, which is the compatibility case; and an + empty set on a state carrying no parentage tags, which is every group made + before this. +- A pairing-order test, because the reason for pairwise emission is a build that + does not exist yet: assemble a two-parent state, read it with the *old* + first-tag-wins parsers, and assert the pair that comes out is coherent. +- `SignedGroupKeyStateTest` (jvmTest) — the one that proves the flow. A real child + key, a real P1 key and state, a real P2 key, a real certificate by P2 over the + child, a real **re-announced** state carrying both, and the assertion that + `stateFrom` accepts it and rejects each single-field mutation. +- `SubgroupDaoJvmTest` — the new schema round-trips the table, a newer state replaces + the parentage rows of an older one, and a re-announcement that drops a parent + leaves no row behind. +- `SubgroupManagerJvmTest` — the two guard tables, and the candidate filter: a room + this device is in but does not administer is not offered as a parent, and neither + is one it administers but holds no share of. +- `ChatMessageJvmTest` — the 30329 arm's second branch: a certificate about this room, + signed by another, writes the offer line; one signed by neither this room nor the + parent it names writes nothing. + +## Rollout + +Additive apart from the migration. + +`assembleTags` emits pairs only where a lineage is passed, so every state already +signed verifies exactly as it did. The plural parsers read a single pair as a set +of one. The new checks fire only on a state carrying more than one pair. +`certifies` does not change. **The founding flow is not touched at all** — a +subgroup is still founded with one parent, and multi-parent is reached by founding +normally and then adopting. + +An older build meeting a two-parent state files a true, partial lineage — one +parent, verified — as long as the tags are pairwise. An older build meeting a +re-announced *single*-parent state sees an ordinary key state and replaces its row, +which is correct. + +The migration is the only non-additive part, and the risk in it is the write path +rather than the read: `replace` becoming transactional is where a bug would put a +stale parent on a state that no longer claims it. + +Phase 1 (the wire) and phase 2 (the schema) can ship dark. The rest is inert +without the two rungs, so the flag, if one is wanted, is again the button. + +## Considered and rejected + +**Founding a subgroup with two parents at once.** The version this question +replaced. An earlier draft rejected it on costs Phase 10 has since removed — the +member-derived ceremony room and the parent set as a `DkgSession` discriminator — so +the argument has to be made again, and it comes out the same way for a better reason. + +**Phase 10 makes founding with two parents harder, not easier.** The ceremony now runs +in *the parent's room*, and a child with two parents has two candidate rooms and no +principled way to choose: `DkgSession.chatRoomId` is one column, the ceremony's +messages ride that room's MLS transport, and the members of the other parent — who may +be admins of the child — are not in it to receive them. Running it in both means two +ceremonies and two keys, which is two children. Running it in one means the other +parent's admins take part in a ceremony held somewhere they cannot see, which is not a +thing MLS will do. + +Adoption sidesteps the question entirely by not holding a ceremony, and what remains +against founding-with-two is unchanged: it still needs a co-parent tag so certificates +can agree on the set, a coordinator who administers both parents, and two quorums +answering before the child room exists at all — and it still cannot adopt a group that +already exists, which is the case people actually have. Founding with two parents stays +reachable: found with one, adopt the second, and the room is a few minutes old instead +of a few seconds. + +**A separate adoption kind, leaving the key state alone.** Cleaner-sounding: a new +event by the child's quorum saying "we accept P2", so the founding fact is never +rewritten. It splits the lineage across a founding key state plus N adoption +events, and then a device holding the state but missing adoption #2 has an +incomplete lineage **and no way to tell that it is incomplete**. One replaceable +statement is always whole or absent. That property is worth more than the +tidiness of not re-signing. + +**Filing foreign certificates on the child's devices.** Closed by +`GroupSignedEvent`'s foreign key onto `ChatRoom`, and unnecessary: the certificate +rides inside the key state. + +**An `exclusive` flag on the certificate**, meaning "we certify C on condition it +has no other parent", enforced by dropping any state whose set has more than one +member when any certificate claims it. Cheap and checkable, and it hands a group +that has gone quiet a permanent veto over a group that has not. If exclusivity is +ever wanted it should be a social arrangement, not a tag. + +**Requiring every certificate to agree on the parent set.** Impossible here, and +the reason is the interesting part: P1 signed before P2 was a possibility. See +[the invariant](#the-invariant-and-how-it-differs-from-founding-time-multi-parent). + +## Found while reading + +Six things, none caused by this, all worth knowing. + +**A built, unreachable path would put any group-signed event on public relays.** +`BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in +one button. Argued above as the +[channel that must not be used](#the-one-channel-that-must-not-be-used-and-it-is-already-built); +listed again here because it is a fact about the tree rather than about this design, +and the next person to wire a broadcast button will not be reading this note. + +**`ChatRoom` is keyed by the group id alone, not by (group, profile).** So two +profiles on one device cannot both hold one group — the second one's Welcome is +refused by `679d5249` — while rows belonging to different profiles sit in one database +with nothing scoping a read by id across them. + +**`FrostSigningManager.canSign` is device-wide, not profile-wide.** +`getKeyHoldingSessions()` carries no identity predicate although `DkgSession` has the +column, so the active profile can resolve a key whose share another profile +generated. Device-local and pre-existing, and a profile-isolation gap rather than a +cross-user one, but it is load-bearing for every "can this act" check in the app and +not only for the ones here. + +**`ChatRoom.parentChatRoomId` is write-only in production.** +`MarmotGroupCreation` sets it; its only reader is +`ChatRoomDao.observeByParentChatRoomId`, whose only callers are tests. So are +`GroupKeyStateDao.getByParentChatRoomId`, its `observe` sibling, and +`DkgSessionDao.getByParentChatRoomId`. The live reads both go elsewhere — +`parentOf` off `GroupKeyState`, `subgroupsOf` off the certificates. + +**`ProposedEvent`'s 30326 arm says nothing about parentage.** It shows the path +and the ceremony id. Today that is harmless, because a key state carrying a +parentage is only ever proposed at founding, where the proposer is the +coordinator who chose it. It stops being harmless the moment a key state can be +re-announced. + +**`GroupSignedEvent` cannot hold another group's event**, by its foreign key onto +`ChatRoom`. Worth knowing before designing anything that wants to keep evidence +about a group this device is not in. + +**The description hint from Phase 6 was never built.** +[subgroups.md](./subgroups.md#the-description-carries-the-parent-too-as-a-hint) +says `SharedKeyDerivation.describe` gains a `Parent group:` line so a member +welcomed into a subgroup after its founding learns it is somebody's child. +`describe(purpose, path)` has no such parameter and emits no such line — so the +"one member and one problem" that section describes is unaddressed even for one +parent. It is also unfixable by this design for a *later* parent, since the +description is baked into the epoch-0 group context and adoption happens after it. + +## Rough size + +What it estimated, against what it took: + +| | estimated | actual | +|---|---|---| +| wire | 1 | 2 — `GroupKeyStateEvent` plus the `SubgroupLineage` type beside it | +| schema | 5 | 7 — two new files (`GroupKeyStateParent`, `LocalGroupKeyState`), the migration, two DAOs, `MantraDatabase`, the builder | +| managers | 2 | 3 — `MarmotGroupCreation` as well, losing the parameter nothing fed any more | +| repositories | 4 | 2 — one interface and one implementation; `DkgRepository`'s changes were return types the note did not count | +| text | 2 | 2 | +| UI | 5 | 10 — a route, a screen, a view model and a UI state for the picker, plus the detail screen's four and the strings | +| tests | 8 | 7 files, 53 new cases | + +39 files, and the founding flow is not among them: a subgroup is still founded with +one parent, and the second arrives by adoption. + +454 common tests, 962 jvm tests, `m3Audit` meets every budget.