From 1e7ddd844abbd17f9fc0c24607f852a12df47ba7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:26:39 +0200 Subject: [PATCH 1/6] feat(subgroups): schema 19 -- the two things a ceremony's own room used to answer A subgroup's ChillDKG is about to move out of the sibling NIP-17 room derived from its admins and into the parent's Marmot room. That room answers two questions the sibling room answered for free, and it answers both of them wrong. **Who the ceremony is with.** The sibling room's membership *was* the participant set -- it was derived from it -- so `memberPublicKeys(localChatRoom)` was the admin list, and three callers read it that way: `broadcast`'s p-tags, the birth certificate's `adminPublicKeys`, and the members `MarmotGroupCreation` welcomes into the child. The parent's room is a superset, so each of those would silently name every person the subgroup is *not*. `participantPublicKeys` is the set the ceremony was opened on, sorted and comma-joined the way `publicShares` already stores a list. **What the room it makes is called.** The sibling room carried the subgroup's name as its subject, put there by the coordinator and delivered to everyone else by `getOrCreateNip17ChatRoom` reading the proposal's `subject` tag. A ceremony in the parent's room has no room of its own to be named, and the parent's name is the one name a child must not take -- `proposeBirthCertificate` normalises this string into the certificate the parent's quorum signs, and `MarmotGroupCreation` normalises it again onto the room. `subject` is where it lands instead. Both are read off the proposal, which has carried both since `7bccf243` -- the p-tags and the `subject` tag. Nothing new goes on the wire; what changes is where it is kept. Both are nullable and both fall back to the room, which is correct for every row written before this: a ceremony in its own NIP-17 room ran over exactly that room's members and was named after it. **Sorted, because two devices assemble the set differently.** The coordinator builds it from a picker; every other device builds it from the proposal's p-tags plus the sender. `DkgSession.formatParticipants` sorts so those are the same string, which is what makes it something a query can match on. Two queries, both of which exist because `DkgSession.chatRoomId` is about to stop identifying a ceremony: `getLatestSubgroupSessionFor(room, parent, admins)` is what "may I open another" means for a subgroup once the parent's room hosts every one the group ever runs. `getLatestSessionFor(room, parent)` cannot answer it -- two subgroups of one parent share both columns -- and scoping on the room alone would refuse a group's second subgroup on the strength of its first. `getLatestOwnSessionForChatRoom(room)` is `getLatestSessionForChatRoom` with `parentChatRoomId IS NULL`. It exists for `FrostSigningManager.completedKey`'s last fallback, which is the one every parent member welcomed after the group's own ceremony lands on: the parent's room will hold a *completed* ceremony whose key is the child's, and a ceremony run to make a subgroup is never the room's own key. The claim is unverified, but the only direction it can be abused in is a member excluding a ceremony they themselves proposed, whose key they would simply not have proposed. `AutoMigration(18, 19)`: two nullable columns, a shape Room migrates itself. Co-Authored-By: Claude Opus 5 --- .../19.json | 5688 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 16 +- .../compose/database/dao/DkgSessionDao.kt | 77 +- .../compose/database/model/DkgSession.kt | 63 + 4 files changed, 5833 insertions(+), 11 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/19.json diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/19.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/19.json new file mode 100644 index 00000000..0068c144 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/19.json @@ -0,0 +1,5688 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "db216d88664a106b4b72a468f6d1f692", + "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, `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 )", + "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": "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 + }, + { + "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, `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 )", + "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": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "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": [ + "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": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'db216d88664a106b4b72a468f6d1f692')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index b8a940d4..39968228 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 18, + version = 19, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -298,6 +298,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // clock, which is correct for them: nothing that predates subgroups ran // two ceremonies in one room. AutoMigration(from = 17, to = 18), + // v19 adds two nullable DkgSession columns, both of which the ceremony's + // room used to answer and no longer can: a subgroup's ChillDKG now runs + // in the **parent's** Marmot room rather than in a NIP-17 room derived + // from the child's admins. See docs/subgroups.md. + // + // `participantPublicKeys` is who the ceremony is with -- a subset of the + // parent's members, so the room's roster is the wrong set -- and + // `subject` is what the room it will make is to be called, which used to + // be the ceremony room's own name. Both are read off the proposal, which + // has carried both since before this move; what changed is where they + // land. Rows written before this read back null and fall back to the + // room, which is correct for them: a ceremony held in its own NIP-17 room + // ran over exactly that room's members and was named after it. + AutoMigration(from = 18, to = 19), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index 525af65b..4f542730 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -27,21 +27,44 @@ interface DkgSessionDao { @Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * The room's newest ceremony **of its own**, newest first -- never one it is + * only hosting on a subgroup's behalf. + * + * The distinction did not exist while every ceremony ran in a NIP-17 room of + * its own. It does now: a subgroup's ChillDKG runs in the parent's Marmot + * room, so that room holds a completed ceremony whose key is the *child's*, + * and a caller asking "what key does this room sign with" must not be handed + * it. That is `FrostSigningManager.completedKey`'s last fallback, which is + * reached by every parent member who holds no key-state row -- which is + * every member welcomed after the parent's own ceremony. + * + * A ceremony run to make a subgroup is never the room's own key, and + * `parentChatRoomId` is the only thing that has to be read to know it. It is + * an unverified claim, but not a dangerous one here: the direction it can be + * abused in is a member *excluding* a ceremony from this answer, and a + * ceremony they can exclude is one they proposed, whose key they would + * simply not have proposed at all. + */ + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND parentChatRoomId IS NULL " + + "ORDER BY createdAt DESC LIMIT 1" + ) + suspend fun getLatestOwnSessionForChatRoom(chatRoomId: String): DkgSession? + /** * The room's newest ceremony *for one purpose*: the group's own when * [parentChatRoomId] is null, or the subgroup of that parent when it is not. * - * A room can hold more than one. The ceremony room for a set of admins is - * derived from those admins, so a subgroup whose admins are everybody lands - * in the room the group's own ceremony was held in -- and that is a legitimate - * subgroup rather than a mistake, because a subgroup is a logical division and - * not a smaller membership. + * A room routinely holds more than one, and since the move it is the ordinary + * case rather than a corner: a parent's Marmot room hosts every subgroup + * ceremony the group ever runs, alongside whatever it holds of its own. + * `DkgSession.parentChatRoomId` is what tells those apart. * - * `DkgSession.parentChatRoomId` is what tells them apart, and it is enough - * because it is the only thing that differs: two ceremonies in one room are - * either one group's and one subgroup's, or two subgroups' of different - * parents. Two subgroups of the *same* parent over the *same* admins would - * still collide, and that pair is one subgroup asked for twice. + * It does **not** tell two subgroups of the same parent apart -- they share + * both columns. Use [getLatestSubgroupSessionFor] where the question is which + * subgroup; this answers "does this room hold a ceremony for that parent at + * all", which is what a screen scoped to one lineage wants. * * Everything else about a ceremony is already keyed by session id -- the * messages, the approvals, the transcript -- so nothing below this needed to @@ -63,6 +86,40 @@ interface DkgSessionDao { ) fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow + /** + * The newest ceremony making a subgroup of [parentChatRoomId] over exactly + * these admins, or null if this device knows of none. + * + * What "may I open another" means for a subgroup, now that the ceremony runs + * in the parent's room. It used to be answered by the room: the ceremony room + * was derived from the admins, so the same admins were the same room and + * [getLatestSessionFor] folded a second ask into the first. The parent's room + * is the same room for every subgroup it ever makes, so that scoping would + * refuse the parent's *second* subgroup on the strength of its first. + * + * [participantPublicKeys] is the same sorted string + * `DkgSession.formatParticipants` writes, which is why it is sorted: the + * coordinator builds the set from a picker and every other device from + * p-tags, and the two have to match as strings for this to find anything. + * + * Deliberately not scoped by stage -- the caller decides what a *finished* + * ceremony means. `ChillDkgRitualManager.proposeRitual` folds into one that + * is still running and opens a new one otherwise, because a subgroup that + * has been made is not a reason to refuse making another over the same + * people. + */ + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "parentChatRoomId = :parentChatRoomId AND " + + "participantPublicKeys = :participantPublicKeys " + + "ORDER BY createdAt DESC LIMIT 1" + ) + suspend fun getLatestSubgroupSessionFor( + chatRoomId: String, + parentChatRoomId: String, + participantPublicKeys: String + ): DkgSession? + /** * Every ceremony this room holds, newest first. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt index 0563664b..9c1abc09 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt @@ -62,6 +62,43 @@ data class DkgSession( /** The `n`: how many members have to show up before params can be built. */ val participantCount: Int, + /** + * Everyone the ceremony is being run with, comma-separated hex, sorted -- + * or null on a row written before this column existed. + * + * The room used to answer this. It cannot any more: a subgroup's ceremony + * runs in the **parent's** room, over a subset of the parent's members, so + * the room's roster is the wrong set and reading it would put every parent + * member in the child's admin list. The set is the proposal's p-tags plus + * its sender, which is the same set every device derives `n` from, so what + * is stored here cannot disagree with the ceremony it describes. + * + * Sorted so two devices that assembled the set in different orders store the + * same string, which is what lets [DkgSessionDao.getLatestSubgroupSessionFor] + * match on it. + * + * Null falls back to the room, which is right for every row that predates + * this: a ceremony before subgroups moved ran in a room whose members were + * exactly its participants. + */ + val participantPublicKeys: String? = null, + + /** + * What the room this ceremony's key derives is to be called, as the proposal + * claimed -- or null for a ceremony that names nothing. + * + * Off the same `SubjectTag` the proposal has always carried. It used to land + * on the ceremony's own NIP-17 room, which was the subgroup's name written + * somewhere it could be read back; a ceremony held in the parent's room has + * no room of its own to be named, and the parent's name is somebody else's. + * + * Unverified, like [parentChatRoomId] and for the same reason -- it is a + * claim on a proposal. What checks it is the birth certificate, which the + * parent's quorum signs over this very name, and which every admin sees + * before agreeing to it. + */ + val subject: String? = null, + val stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS, /** This device's ChillDKG host public key (33-byte compressed, hex). */ @@ -168,4 +205,30 @@ data class DkgSession( ?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } } ?.map { PublicKey(ByteVector(it.hexToByteArray())) } ?.takeIf { it.isNotEmpty() } + + /** + * Everyone this ceremony is being run with, or null on a row that predates + * the column and so still means "the room's members". + * + * Callers that need an answer either way go through + * `ChillDkgRitualManager.participantsOf`, which falls back to the room. + */ + fun participantPublicKeySet(): Set? = participantPublicKeys + ?.split(",") + ?.mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + + companion object { + /** + * The stored form of a participant set: sorted, comma-separated hex. + * + * Sorted because the set is the same set whichever order it was + * assembled in -- the coordinator builds it from a picker and every + * other device from p-tags -- and two devices storing two strings for + * one ceremony would make the column useless for matching on. + */ + fun formatParticipants(publicKeys: Set): String? = + publicKeys.takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",") + } } From 1c0acd546a8d6140d598d91fed4dea3339628cb1 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:27:06 +0200 Subject: [PATCH 2/6] feat(subgroups): put the key ceremony on both transports, p-tags and all `ChillDkgRitualManager` ran on gift wraps only. It now runs on whichever transport the room it is in has -- a gift-wrapped rumor in a NIP-17 room, an MLS application message in a Marmot one -- which is the shape `FrostSigningManager` has had since `113eda9f`, and for the same underlying reason: a group has to say something before it owns the room the saying is about. Nothing is wired to the new arm yet. A group's own ceremony still runs in its NIP-17 room and a subgroup's still runs in the sibling room derived from its admins; what changes here is that the manager stops assuming which. **`broadcast` reads the room and writes to the matching store.** MLS state present is what marks a room Marmot -- the same reading `sendChatMessage` makes when it chooses between a group event and gift wraps. `replayStoredMessages` has to make that reading again, because a ceremony's backlog is in whichever store its messages were queued in and looking in the wrong one is a stall with nothing to blame it on. **The p-tags stay on both, unlike `FrostSigningManager`'s, and this is the one thing here that would be a disaster to get wrong.** There a p-tag is an address and a group event needs none, because the message is encrypted to the whole tree and the signer set comes from the ceremony's host keys either way. Here the p-tag set *is* the participant set: `acceptProposal` builds `n` out of it on every device and `n` is hashed into the session identity, so a Marmot proposal without them leaves every receiver unable to say what they were invited to. In a room whose membership is wider than the ceremony they are also the only thing marking who is in it, and a member who publishes a host key is in that group's signing quorum for good. Inside MLS encryption, naming them leaks nothing. They are also read off the *session* rather than off the room now, via `participantsOf`, and sorted. A room whose membership is wider than the ceremony is exactly the case this is for. **`processRitualPayload` takes an `Event`.** The rumor as its sender wrote it, which is the shape both transports hand over -- `NostrDao` already rebuilt one from a decrypted gift wrap for the FROST arm and now does the same here. `sig` is empty on both and nothing reads it: what vouches for the author is the seal or the MLS frame, not a signature on the payload. `record` and `isFromCoordinator` follow. **`proposeRitual` grows two parameters and splits its guard.** `participantPublicKeys` defaults to the room's members, which is the whole story where the room is the participant set; `subject` defaults to the room's name, but only for a ceremony with no parent -- falling back there would name a child after its parent. The guard now asks two different questions. A ceremony with no parent is scoped by room as before. One with a parent is matched on `(room, parent, admins)` and folds only into a ceremony that is still *running*: a completed one means that subgroup was made, and a group is entitled to a second run by the same people. That was impossible while the ceremony room was derived from its admins -- asking again handed back the first ceremony's key, so the "new" subgroup was the old one under a new name. A new `require`, because the old invariant stops holding for free: everyone in a ceremony has to be able to hear it. A gift wrap is sealed per p-tag so this was true by construction; a group event reaches the MLS tree and nobody else, so a participant outside it is an `n` that can never be met. Placed behind the duplicate guard, so a running ceremony is still handed back whatever the room's rows have since done. **`NostrDao` dispatches the DKG kinds on the Marmot arm** beside the FROST ones it already did, and `ChatMessage.applyInnerEvent` returns null for them the way it does for `FrostSigningEvents.ALL` -- the manager writes the transcript of a ceremony itself, and an "unsupported" row would be a second, worse account of it. A member of the room who was not p-tagged never reaches either: their `acceptProposal` drops the proposal and no session is opened. **A ceremony with a parent says so in the transcript.** The line lands in a room that already has a key, and "started a shared key ceremony. It will take 2 of 3 members to sign with the key" reads there as though *this* group were getting a new one. It is not -- the quorum quoted is the child's, over the child's admins -- and that is the sort of misreading that gets somebody to approve a step they did not follow. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 60 ++- .../compose/database/model/ChatMessage.kt | 16 + .../compose/managers/ChillDkgRitualManager.kt | 433 +++++++++++++----- 3 files changed, 391 insertions(+), 118 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 5f204355..4750536b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -878,7 +878,22 @@ abstract class NostrDao( ChillDkgRitualManager.processRitualPayload( database = database, localChatRoom = localChatRoom, - giftWrapPayload = decryptedGiftWrapPayload, + // The rumor as its sender wrote it, the + // shape a Marmot inner event arrives in + // too -- the manager reads one protocol + // off both transports. `sig` is empty + // because a gift-wrapped rumor carries + // none; the seal is what vouches for the + // author. + innerEvent = Event( + id = decryptedGiftWrapPayload.id, + pubKey = decryptedGiftWrapPayload.publicKey, + createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds, + kind = decryptedGiftWrapPayload.kind, + tags = decryptedGiftWrapPayload.tags, + content = decryptedGiftWrapPayload.content, + sig = "" + ), userPublicKey = activeKeyPair.pubKey.toHex(), nostrPrivateKey = activeKeyPair.privKey!! ) @@ -1320,12 +1335,20 @@ abstract class NostrDao( persistMarmotChatMessage(chatMessage) } - // A FROST signing message for this group. Driven from - // here rather than from ChatMessage because the + // A signing or ceremony message for this group. Driven + // from here rather than from ChatMessage because each // manager needs the room to publish its own replies - // into, and because it writes its transcript lines - // itself. The manager is idempotent, so a redelivered - // message re-runs a step it has already taken. + // into, and because both write their transcript lines + // themselves. Both are idempotent, so a redelivered + // message re-runs a step already taken. + // + // A ceremony arrives here when the group is standing up + // a subgroup: the child's ChillDKG runs in this room, + // over the admins the proposal p-tags, because the + // child has no room of its own until the ceremony has + // produced the key its id is derived from. A member of + // this room who was not picked drops the proposal in + // `acceptProposal` and never opens a session. // // A GroupKeyStateEvent is deliberately not dispatched // here. It is no longer something a member says -- @@ -1334,14 +1357,25 @@ abstract class NostrDao( // every other event the group has signed. if (groupEventResult is GroupEventResult.ApplicationMessage) { Event.fromJsonOrNull(groupEventResult.innerEventJson) - ?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) } ?.let { innerEvent -> - FrostSigningManager.processSigningPayload( - database = database, - localChatRoom = localChatRoom, - innerEvent = innerEvent, - userPublicKey = activeKeyPair.pubKey.toHex() - ) + when { + FrostSigningEvents.isFrostSigningKind(innerEvent.kind) -> + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + + DkgRitualEvents.isDkgRitualKind(innerEvent.kind) -> + ChillDkgRitualManager.processRitualPayload( + database = database, + localChatRoom = localChatRoom, + innerEvent = innerEvent, + userPublicKey = activeKeyPair.pubKey.toHex(), + nostrPrivateKey = activeKeyPair.privKey!! + ) + } } } } 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 cddd1527..f6db7723 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 @@ -21,6 +21,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -1260,6 +1261,21 @@ data class ChatMessage( // account of the same thing. in FrostSigningEvents.ALL -> null + // A key ceremony's own protocol messages, for the same reason. + // These reach a Marmot room when the group is standing up a + // subgroup: the child's ChillDKG runs in the parent's room, + // because the child has no room until the ceremony has made the + // key its id is derived from. `ChillDkgRitualManager` applies + // them and writes the ladder of lines naming who joined, who + // contributed and who confirmed. + // + // Null for every member of the room, including the ones the + // ceremony is not with. They were never in the participant set + // and their device drops the proposal, so the alternative to no + // line at all is an "unsupported" one for a ceremony that is + // none of their business. + in DkgRitualEvents.ALL -> null + // The group saying what key one of its rooms signs with, arriving // here the way every group-signed event does: applied by // `FrostSigningManager` once a quorum has signed the proposal it diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index fe8a165f..5e2a9c6a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -5,6 +5,7 @@ import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.database.model.types.DkgApprovalStep @@ -12,12 +13,14 @@ import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.utils.RandomInstance import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 @@ -32,18 +35,38 @@ import kotlin.time.Clock import kotlin.time.Instant /** - * Runs a ChillDKG ritual over a NIP-17 group. + * Runs a ChillDKG ritual in a group's room, on whichever transport that room has. * - * The group is the participant set, the member who opens the ritual is the - * coordinator, and every protocol message travels as a gift-wrapped rumor on - * the kinds in [DkgRitualEvents] — the same sealing and broadcast pipeline chat - * messages already use, so there is no second transport to operate. + * The participant set is who the proposal p-tags, the member who opens the ritual + * is the coordinator, and every protocol message travels on the kinds in + * [DkgRitualEvents] — as a gift-wrapped rumor in a NIP-17 room and as an MLS + * application message in a Marmot one, which are the two pipelines chat messages + * already use, so there is no second transport to operate. + * + * ### Two transports, because a subgroup's ceremony is held by its parent + * + * A group's own ceremony runs in the NIP-17 room its members share: there is no + * MLS tree yet, and cannot be, since the room the key will make is derived from + * the key the ceremony is about to produce. + * + * A *subgroup's* ceremony runs in the **parent's** Marmot room. The parent + * already exists, already holds exactly the people a subgroup can be drawn from, + * and already signs the child's birth certificate there — so the alternative was + * a sibling NIP-17 room whose id was an aggregate of the child's admins, which + * meant one admin set could hold one subgroup ever. [broadcast] is the only place + * that knows the difference; everything above it is the same protocol either way. + * + * The p-tags stay on **both**, unlike `FrostSigningManager`'s. There they are an + * address list and a Marmot message needs none; here the p-tag set *is* the + * participant set every device derives `n` from, and in the parent's room that is + * a subset of who can read the message. Inside MLS encryption, naming them leaks + * nothing. * * The coordinator is a participant too. ChillDKG treats the coordinator as * untrusted (it relays but cannot learn secrets or bias the key), so opening * the ritual buys it no authority here — only the job of aggregating. That is * why any member may open one: there is nothing to gain by being the one who - * does, and a NIP-17 room records no creator to privilege. + * does. * * ### Why this is driven by arriving messages * @@ -109,9 +132,38 @@ object ChillDkgRitualManager { fun canRunRitual(localChatRoom: LocalChatRoom): Boolean = memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS + /** + * Everyone a ceremony is being run with — which is not the same question as + * who is in the room it runs in. + * + * A subgroup's ceremony is held in the parent's room over a subset of the + * parent's members, so the room's roster names people the ceremony has + * nothing to do with. [DkgSession.participantPublicKeys] is the set the + * ceremony was opened on, taken from the proposal's p-tags on every device + * that did not open it. + * + * The room is the fallback, and only for rows written before that column + * existed: every ceremony then ran in a room whose members were exactly its + * participants, so reading the room gave the same answer. + */ + fun participantsOf(session: DkgSession, localChatRoom: LocalChatRoom): Set = + session.participantPublicKeySet() ?: memberPublicKeys(localChatRoom) + /** * Opens a ritual, making this device the coordinator. A room already running - * one gets that one back rather than a second. + * one for the same purpose gets that one back rather than a second. + * + * [participantPublicKeys] is who the ceremony is with, and defaults to the + * room's members -- which is the whole story for a group's own ceremony, + * where the room *is* the participant set. A subgroup's ceremony runs in the + * parent's room over a subset of it, so that caller names the set, and this + * device is added to it either way: opening a ceremony is being in it. + * + * [subject] is what the room this ceremony's key derives will be called. A + * group's own ceremony takes the name of the room it runs in, since the room + * it makes administers that group; a subgroup's is named by the coordinator, + * and is carried to the other admins on the proposal because there is no + * longer a room of its own for them to read it off. * * Throws when the group or the threshold cannot support one; the UI checks * both before offering the button, so reaching either is a bug rather than a @@ -123,8 +175,15 @@ object ChillDkgRitualManager { userPublicKey: HexKey, nostrPrivateKey: ByteArray, threshold: Int, - parentChatRoomId: HexKey? = null + parentChatRoomId: HexKey? = null, + participantPublicKeys: Set? = null, + subject: String? = null ): DkgSession { + // Built the way a receiver rebuilds it from the proposal — the p-tags + // `broadcast` writes, plus this device — so both sides count the same `n` + // even if the room's own rows have drifted. + val members = (participantPublicKeys ?: memberPublicKeys(localChatRoom)) + userPublicKey + // A room runs one ceremony at a time *for one purpose*, and this is // reachable twice now that a robust group opens one as it is created: the // room id is derived from its members, so making the same group again @@ -133,36 +192,66 @@ object ChillDkgRitualManager { // and the key the first one produced would be left with nothing pointing // at it. A failed ritual is not running, and is there to be replaced. // - // Scoped by [parentChatRoomId] rather than by room alone. A subgroup's - // ceremony room is derived from its admins, so a subgroup whose admins are - // the whole group lands in the room the group's own ceremony was held in - // -- and that is a legitimate subgroup, since a subgroup is a logical - // division rather than a smaller membership. Refusing it on the room would - // hand back the *group's* key and quietly make the child the parent. + // What "the same purpose" means differs by which kind of ceremony this + // is, because a parent's room hosts every subgroup ceremony the group + // ever runs. Scoping a subgroup's on the room would refuse the parent's + // *second* subgroup on the strength of its first. // - // Checked before the arguments are, because a running ritual makes the + // Checked before the threshold is, because a running ritual makes the // requested threshold moot -- it settled that question when it opened. - database.dkgSessionDao().getLatestSessionFor(localChatRoom.chatRoom.id, parentChatRoomId) - ?.takeIf { it.stage != DkgRitualStage.FAILED } - ?.let { running -> - logger.i( - "Room ${localChatRoom.chatRoom.id} is already running ritual " + - "${running.id}; not opening another" - ) - return running + val running = if (parentChatRoomId == null) { + database.dkgSessionDao() + .getLatestSessionFor(localChatRoom.chatRoom.id, null) + ?.takeIf { it.stage != DkgRitualStage.FAILED } + } else { + // Same parent, same admins -- and still running. A *completed* one is + // deliberately not a reason to refuse: it means that subgroup was + // made, and a group is entitled to a second one run by the same + // people. That was impossible while the ceremony room was derived + // from the admins, since asking again handed back the first + // ceremony's key and the "new" subgroup was the old one renamed. + database.dkgSessionDao().getLatestSubgroupSessionFor( + chatRoomId = localChatRoom.chatRoom.id, + parentChatRoomId = parentChatRoomId, + participantPublicKeys = DkgSession.formatParticipants(members).orEmpty() + )?.takeIf { + it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE } + } + + if (running != null) { + logger.i( + "Room ${localChatRoom.chatRoom.id} is already running ritual " + + "${running.id}; not opening another" + ) + return running + } + + // Everyone in a ceremony has to be able to hear it. In a NIP-17 room a + // gift wrap is sealed per p-tag so this was true by construction; in a + // Marmot room a message reaches the MLS tree and nobody else, so a + // participant outside it is an `n` that can never be met. Checked here + // because `SubgroupManager.refuseSubgroup` checking it is a screen not + // drawing something, which is not a guard. + val room = memberPublicKeys(localChatRoom) + localChatRoom.chatRoom.userPublicKey + require(room.containsAll(members)) { + "A ceremony in ${localChatRoom.chatRoom.id} cannot include " + + "${(members - room).joinToString { it.take(8) }}, who are not in it" + } - // Built the way a receiver rebuilds it from the proposal — the p-tags - // `broadcast` writes, plus this device — so both sides count the same `n` - // even if the room's own rows have drifted. - val members = memberPublicKeys(localChatRoom) + userPublicKey require(members.size >= MINIMUM_PARTICIPANTS) { - "A shared key needs at least $MINIMUM_PARTICIPANTS members, this room has ${members.size}" + "A shared key needs at least $MINIMUM_PARTICIPANTS members, this ceremony has ${members.size}" } require(threshold in ChatRoomType.quorumRange(members.size)) { "Quorum $threshold is outside ${ChatRoomType.quorumRange(members.size)} for ${members.size} members" } + // A subgroup is named by whoever is making it. Falling back to the room + // there would name a child after its parent, which is the one name it + // must not have -- the certificate the parent signs is over this string. + val ceremonySubject = subject?.takeIf { it.isNotBlank() } + ?: localChatRoom.chatRoom.subject?.takeIf { parentChatRoomId == null } + val sessionId = RandomInstance.bytes(32).toHex() val session = DkgSession( @@ -174,6 +263,10 @@ object ChillDkgRitualManager { // The `n` every other device will derive from this proposal's p-tags, // which `broadcast` assembles from exactly this set. participantCount = members.size, + // And the set itself, because the room can no longer be asked: a + // subgroup's ceremony runs in the parent's room over a subset of it. + participantPublicKeys = DkgSession.formatParticipants(members), + subject = ceremonySubject, hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(), // Fresh per session. Persisted because participantStep1/2 are pure in // it, which is what makes the whole ritual restart-safe. @@ -208,13 +301,15 @@ object ChillDkgRitualManager { // bytes per round to say something already settled -- and would give a // later message a say in what an opened ceremony is for. includeParent = true, - // The room's name, which until now reached nobody but its creator. - // `NostrDao.getOrCreateNip17ChatRoom` has always read a subject off the - // payload that first tells it about a room, and a ritual proposal is - // routinely that payload -- so without this every member but the - // coordinator watched an untitled chat appear with a key ceremony - // already running in it. - subject = localChatRoom.chatRoom.subject + // What the room this key derives is to be called. For a group's own + // ceremony that is the NIP-17 room's name, which until this tag + // existed reached nobody but its creator -- + // `NostrDao.getOrCreateNip17ChatRoom` has always read a subject off + // the payload that first tells it about a room, and a ritual proposal + // is routinely that payload. For a subgroup it is the only way the + // name travels at all, since the child has no room yet and the room + // this runs in is the parent's. + subject = ceremonySubject ) publishHostKey(database, localChatRoom, session) @@ -226,49 +321,55 @@ object ChillDkgRitualManager { * Feeds one inbound ritual message in and advances the ritual as far as it * will go. Safe to call twice with the same message: every write is keyed * and every step recomputed from stored inputs. + * + * [innerEvent] is the rumor as its sender wrote it, whichever transport + * carried it -- the decrypted gift wrap in a NIP-17 room, the inner event of + * a kind:445 in a Marmot one. `sig` is empty on both and nothing here reads + * it: what vouches for the author is the seal or the MLS frame, not a + * signature on the payload. */ suspend fun processRitualPayload( database: MantraDatabase, localChatRoom: LocalChatRoom, - giftWrapPayload: GiftWrapPayload, + innerEvent: Event, userPublicKey: HexKey, nostrPrivateKey: ByteArray ) { - val sessionId = DkgRitualEvents.parseSessionId(giftWrapPayload.tags) + val sessionId = DkgRitualEvents.parseSessionId(innerEvent.tags) if (sessionId == null) { - logger.w("DKG payload ${giftWrapPayload.id} has no session tag; dropping") + logger.w("DKG payload ${innerEvent.id} has no session tag; dropping") return } - val session = if (giftWrapPayload.kind == DkgRitualEvents.PROPOSAL) { + val session = if (innerEvent.kind == DkgRitualEvents.PROPOSAL) { acceptProposal( database = database, localChatRoom = localChatRoom, - giftWrapPayload = giftWrapPayload, + innerEvent = innerEvent, sessionId = sessionId, userPublicKey = userPublicKey, nostrPrivateKey = nostrPrivateKey ) } else { - // Not knowing the session is normal: gift wraps carry a randomised - // created_at and relays hand them back in no particular order, so a - // round-1 message routinely lands before the proposal that opens the - // ritual. The payload is already stored, and [acceptProposal] replays - // it once the proposal turns up. + // Not knowing the session is normal: relays hand events back in no + // particular order -- and gift wraps carry a randomised created_at on + // top of that -- so a round-1 message routinely lands before the + // proposal that opens the ritual. The payload is already stored, and + // [acceptProposal] replays it once the proposal turns up. database.dkgSessionDao().getSessionById(sessionId) } if (session == null) { - logger.i("No DKG session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored") + logger.i("No DKG session $sessionId for kind ${innerEvent.kind}; leaving it stored") return } if (session.stage == DkgRitualStage.FAILED) { - logger.i("Ritual $sessionId already failed; ignoring kind ${giftWrapPayload.kind}") + logger.i("Ritual $sessionId already failed; ignoring kind ${innerEvent.kind}") return } - if (!record(database, session, giftWrapPayload)) return + if (!record(database, session, innerEvent)) return advance( database = database, @@ -285,7 +386,7 @@ object ChillDkgRitualManager { private suspend fun acceptProposal( database: MantraDatabase, localChatRoom: LocalChatRoom, - giftWrapPayload: GiftWrapPayload, + innerEvent: Event, sessionId: String, userPublicKey: HexKey, nostrPrivateKey: ByteArray @@ -296,10 +397,15 @@ object ChillDkgRitualManager { // this device's view of the room. `n` is hashed into the session identity, // so two devices that disagree on it cannot complete a ritual together — // and local membership is exactly the thing that drifts between devices. - val members = giftWrapPayload.participantPTags().map { it.pubKey }.toSet() + // + // In the parent's room it is not even meant to match: a subgroup's + // ceremony is run by some of the room, and every member outside the set + // drops the proposal below rather than joining a ceremony they were not + // picked for. + val members = participantsFrom(innerEvent) if (userPublicKey !in members) { - logger.w("DKG proposal $sessionId does not include this device; dropping") + logger.i("DKG proposal $sessionId does not include this device; dropping") return null } @@ -308,7 +414,7 @@ object ChillDkgRitualManager { return null } - val threshold = DkgRitualEvents.parseThreshold(giftWrapPayload.tags) + val threshold = DkgRitualEvents.parseThreshold(innerEvent.tags) if (threshold == null) { logger.w("DKG proposal $sessionId carries no threshold; dropping") return null @@ -330,11 +436,25 @@ object ChillDkgRitualManager { chatRoomId = localChatRoom.chatRoom.id, // Whoever opens the ritual coordinates it. ChillDKG gives the // coordinator no power over the outcome, so there is nothing here to - // reserve for a "room creator" — which NIP-17 does not record anyway. - coordinatorPublicKey = giftWrapPayload.publicKey, + // reserve for a room's creator or its admins — and in the parent's + // room, where a member who is neither can put a proposal on the wire, + // that is what keeps this from needing a permission check. The worst + // such a proposal can do is ask: the birth certificate two steps later + // needs the parent's own quorum, which is the check that was always + // going to be the check. + coordinatorPublicKey = innerEvent.pubKey, userPublicKey = userPublicKey, threshold = threshold, participantCount = members.size, + // The set the count is of, kept so the room never has to be asked. + // In the parent's room asking it would answer with the parent's + // membership, which is every person this ceremony is not with. + participantPublicKeys = DkgSession.formatParticipants(members), + // What the room this key derives is to be called, as claimed. Taken + // as said for the same reason the parent below is: nothing is granted + // on it, and the certificate the parent's quorum signs is over this + // name, so an admin who dislikes it declines that. + subject = DkgRitualEvents.parseSubject(innerEvent.tags), hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(), round1Random = RandomInstance.bytes(32).toHex(), round2AuxRandom = RandomInstance.bytes(32).toHex(), @@ -345,7 +465,7 @@ object ChillDkgRitualManager { // is the p-tags and the threshold is the threshold. What it changes is // that the screen can say what the ceremony is for. The parent's own // signature turns up two steps later, on the birth certificate. - parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(giftWrapPayload.tags) + parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags) ) database.dkgSessionDao().upsert(session) announceStarted(database, session) @@ -358,7 +478,7 @@ object ChillDkgRitualManager { // the ritual can be shown and stored messages replayed; [advance] sends // nothing until [approve] has been called. announceApprovalNeeded(database, session, DkgApprovalStep.HOST_KEY) - replayStoredMessages(database, session) + replayStoredMessages(database, localChatRoom, session) return session } @@ -371,15 +491,50 @@ object ChillDkgRitualManager { * kind, so nothing was actually lost — this reads them back out. Without it a * ritual whose proposal loses the race to its own round-1 messages waits * forever on messages that already arrived. + * + * Out of whichever store the room's transport writes to, which has to be the + * same reading [broadcast] makes: a ceremony in a NIP-17 room has its backlog + * in the gift-wrap payloads and none at all in the inner events, and looking + * in the wrong one is a stall with nothing to blame it on. */ private suspend fun replayStoredMessages( database: MantraDatabase, + localChatRoom: LocalChatRoom, session: DkgSession ) { - val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds( - chatRoomId = session.chatRoomId, - kinds = DkgRitualEvents.ALL.toList() - ).filter { payload -> + val kinds = DkgRitualEvents.ALL.toList() + + val received = if (localChatRoom.chatRoom.mlsGroupState != null) { + database.marmotInnerEventDao() + .getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds) + .map { stored -> + Event( + id = stored.id, + pubKey = stored.publicKey, + createdAt = stored.createdAt.epochSeconds, + kind = stored.kind, + tags = stored.tags, + content = stored.content, + sig = "" + ) + } + } else { + database.giftWrapPayloadDao() + .getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds) + .map { stored -> + Event( + id = stored.id, + pubKey = stored.publicKey, + createdAt = stored.createdAt.epochSeconds, + kind = stored.kind, + tags = stored.tags, + content = stored.content, + sig = "" + ) + } + } + + val stored = received.filter { payload -> payload.kind != DkgRitualEvents.PROPOSAL && DkgRitualEvents.parseSessionId(payload.tags) == session.id } @@ -404,9 +559,9 @@ object ChillDkgRitualManager { private suspend fun record( database: MantraDatabase, session: DkgSession, - giftWrapPayload: GiftWrapPayload + innerEvent: Event ): Boolean { - when (giftWrapPayload.kind) { + when (innerEvent.kind) { DkgRitualEvents.PROPOSAL -> Unit // handled by acceptProposal; host key already published DkgRitualEvents.HOST_KEY, @@ -417,19 +572,19 @@ object ChillDkgRitualManager { // upsert below absorbs that, but a chat row has no key to absorb it // with. val known = database.dkgSessionDao() - .getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null + .getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null database.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = session.id, - participantPublicKey = giftWrapPayload.publicKey, - kind = giftWrapPayload.kind, - payload = giftWrapPayload.content + participantPublicKey = innerEvent.pubKey, + kind = innerEvent.kind, + payload = innerEvent.content ) ) if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -437,38 +592,38 @@ object ChillDkgRitualManager { // them from anyone else lets any member stall the ritual by getting a // bogus one in first, since the real one would then be ignored. DkgRitualEvents.COORDINATOR_ROUND_1 -> { - if (!isFromCoordinator(session, giftWrapPayload)) return true + if (!isFromCoordinator(session, innerEvent)) return true val known = current(database, session).coordinatorRound1 != null update(database, session) { current -> if (current.coordinatorRound1 == null) { - current.copy(coordinatorRound1 = giftWrapPayload.content) + current.copy(coordinatorRound1 = innerEvent.content) } else { current } } if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } DkgRitualEvents.CERTIFICATE -> { - if (!isFromCoordinator(session, giftWrapPayload)) return true + if (!isFromCoordinator(session, innerEvent)) return true val known = current(database, session).certificate != null update(database, session) { current -> if (current.certificate == null) { - current.copy(certificate = giftWrapPayload.content) + current.copy(certificate = innerEvent.content) } else { current } } if (!known) { - announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -476,8 +631,8 @@ object ChillDkgRitualManager { fail( database = database, session = session, - reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}", - culprit = giftWrapPayload.publicKey + reason = "Abandoned by ${innerEvent.pubKey.take(8)}: ${innerEvent.content}", + culprit = innerEvent.pubKey ) return false } @@ -486,12 +641,12 @@ object ChillDkgRitualManager { return true } - private fun isFromCoordinator(session: DkgSession, giftWrapPayload: GiftWrapPayload): Boolean { - if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true + private fun isFromCoordinator(session: DkgSession, innerEvent: Event): Boolean { + if (innerEvent.pubKey == session.coordinatorPublicKey) return true logger.w( - "Ritual ${session.id}: kind ${giftWrapPayload.kind} from " + - "${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring" + "Ritual ${session.id}: kind ${innerEvent.kind} from " + + "${innerEvent.pubKey.take(8)}, who is not the coordinator; ignoring" ) return false } @@ -874,11 +1029,24 @@ object ChillDkgRitualManager { session = session, messageType = ChatMessage.TYPE_DKG_STARTED, // Reads after the actor's name -- see ChatMessage.DKG_AUTHORED_TYPES. Who - // opened a ceremony matters: any member can, and it settles the group's + // opened a ceremony matters: any member can, and it settles a group's // signing quorum for good. - content = "started a shared key ceremony. It will take ${session.threshold} of " + - "${session.participantCount} members to sign with the key, and it finishes once " + - "everyone has taken part.", + // + // A subgroup's ceremony says so, because it lands in the *parent's* room + // and the unqualified sentence reads there as though this group were + // getting a new key. It is not: the quorum quoted is the child's, over + // the child's admins, and the parent's own key is untouched. Getting that + // wrong is how a member approves something they did not follow. + content = if (session.parentChatRoomId != null) { + "started a key ceremony for ${session.subject ?: "a subgroup"}, a subgroup of " + + "this group. It will take ${session.threshold} of " + + "${session.participantCount} of its admins to sign with the subgroup's key, " + + "and it finishes once all of them have taken part." + } else { + "started a shared key ceremony. It will take ${session.threshold} of " + + "${session.participantCount} members to sign with the key, and it finishes " + + "once everyone has taken part." + }, actor = session.coordinatorPublicKey ) @@ -1147,9 +1315,28 @@ object ChillDkgRitualManager { } /** - * Queues a ritual message as a gift-wrap payload. `NotaryViewModel` picks it - * up, seals a copy per participant and broadcasts — the same path chat - * messages take, which is why this needs no transport of its own. + * Queues one of this device's ritual messages for the outbound pipeline, on + * whichever transport the room it is in has. + * + * In a NIP-17 room it is a gift-wrap payload: `NotaryViewModel` picks it up, + * seals a copy per participant and broadcasts. In a Marmot room it is an + * unprocessed inner event, MLS-encrypted and broadcast as a kind:445 for the + * room. Both are the paths chat messages already take, which is why this + * needs no transport of its own. + * + * **The p-tags go on both**, which is where this parts company with + * `FrostSigningManager.broadcast`. There a p-tag is an address, and a group + * event needs none because it is encrypted to the whole tree. Here the p-tag + * set *is* the participant set: `acceptProposal` builds `n` out of it on + * every device, and `n` is hashed into the session identity, so dropping the + * tags in a Marmot room would leave every receiver unable to say what + * ceremony they had been invited to. In the parent's room they are also the + * only thing separating the subgroup's admins from everybody else who can + * read the message, and naming them inside MLS encryption leaks nothing. + * + * Off the session rather than off the room, for that same reason. A + * subgroup's ceremony runs in the parent's room, so the room's roster would + * invite the whole parent into the child's key. */ private suspend fun broadcast( database: MantraDatabase, @@ -1161,15 +1348,19 @@ object ChillDkgRitualManager { includeParent: Boolean = false, subject: String? = null ) { - // One p-tag per member, and the sender is implied rather than tagged — - // together they are the participant set every receiver derives `n` from. - val receiverTags = localChatRoom.localParticipants - .distinctBy { it.participant.participantPublicKey } - .filter { it.participant.participantPublicKey != session.userPublicKey } - .map { localParticipant -> + // Sorted so a receiver comparing two devices' proposals is comparing the + // same bytes, and the sender is implied rather than tagged -- together + // they are the set every receiver derives `n` from. + val relayHints = localChatRoom.localParticipants + .associate { it.participant.participantPublicKey to it.participant.relayHint } + + val receiverTags = participantsOf(session, localChatRoom) + .filterNot { it == session.userPublicKey } + .sorted() + .map { participant -> PTag.assemble( - localParticipant.participant.participantPublicKey, - localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) } + participant, + relayHints[participant]?.let { NormalizedRelayUrl(it) } ) } @@ -1181,7 +1372,10 @@ object ChillDkgRitualManager { ) val createdAt = Clock.System.now().epochSeconds - val giftWrapPayloadId = EventHasher.hashId( + + // The rumor id the outbound pipeline will recompute from these same + // fields when it assembles the event to encrypt, on either transport. + val id = EventHasher.hashId( pubKey = session.userPublicKey, createdAt = createdAt, tags = tags, @@ -1189,16 +1383,45 @@ object ChillDkgRitualManager { kind = kind ) - database.giftWrapPayloadDao().upsert( - GiftWrapPayload( - id = giftWrapPayloadId, - kind = kind, - tags = tags, - createdAt = Instant.fromEpochSeconds(createdAt), - content = content, - chatRoomId = localChatRoom.chatRoom.id, - publicKey = session.userPublicKey + // No MLS state is what marks a room NIP-17 -- the same reading + // `NostrNip17Dao.getOrCreateChatRoom` writes, `sendChatMessage` makes when + // it chooses between a group event and gift wraps, and + // [replayStoredMessages] has to make again to find this message later. + if (localChatRoom.chatRoom.mlsGroupState != null) { + database.marmotInnerEventDao().upsert( + MarmotInnerEvent( + id = id, + publicKey = session.userPublicKey, + kind = kind, + createdAt = Instant.fromEpochSeconds(createdAt), + tags = tags, + content = content, + chatRoomId = localChatRoom.chatRoom.id + ) ) - ) + } else { + database.giftWrapPayloadDao().upsert( + GiftWrapPayload( + id = id, + kind = kind, + tags = tags, + createdAt = Instant.fromEpochSeconds(createdAt), + content = content, + chatRoomId = localChatRoom.chatRoom.id, + publicKey = session.userPublicKey + ) + ) + } } + + /** + * The participant set a proposal names: its p-tags plus its sender. + * + * The sender is in the set without tagging themselves -- they are running the + * ceremony, and a device does not need to be told to send itself a message. + * `GiftWrapPayload.participantPTags` reads exactly this off the other + * transport, and the two have to agree or a ceremony cannot span them. + */ + private fun participantsFrom(innerEvent: Event): Set = + innerEvent.tags.taggedUsers().map { it.pubKey }.toSet() + innerEvent.pubKey } From ba8aa1e2209dc7667486ef576632d914ea6dad55 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:27:24 +0200 Subject: [PATCH 3/6] fix(subgroups): stop a parent resolving its own child's key, and let a child sign Two guards in `FrostSigningManager` that were correct only while every ceremony ran in a room of its own. A parent's Marmot room is about to host its subgroups' ceremonies, and both of these read a ceremony's room as though that could only mean one thing. Neither fails loudly. **`completedKey`'s last fallback is "a ceremony held in this very room", and that stops being the room's own key.** The fallback is not decoration: a room's `GroupKeyState` is signed before the room exists and filed as it is created, so every member welcomed after that -- an invite, a reinstall -- has a room and no state, and lands here. The parent's room will hold a *completed* ceremony whose threshold key belongs to the child, so those members would resolve the child's key for the parent and the group would author events as its own subgroup, with a valid signature and nothing on screen to say so. The certificate a subgroup is born with is exactly one of those events. `getLatestOwnSessionForChatRoom` is the same query with `parentChatRoomId IS NULL`. A ceremony run to make a subgroup is never the room's own key, and that column is all that has to be read to know it. **`signingPath` has one case it cannot self-check, and there are now two rooms in it.** Everywhere else a candidate path is right exactly when walking it reaches the room, which makes the function self-checking rather than trusting -- and the path decides what key the group signs as, so it must never come off a proposal. The exception is the room a ceremony ran in, signing the statement that lets the room the ceremony's key derives be created. That room is not derived from the key at all, so nothing rederives. It used to mean "a NIP-17 room whose ceremony is its own", gated on `mlsGroupState == null`. It now also means "the parent's room, where the ceremony claims that parent" -- without which a subgroup's key state would be signed as the bare threshold key, an identity no room answers to. `key.parentChatRoomId` is an unverified claim off a proposal and admitting it here grants nothing. The signature it enables is by the *child's* key over the *child's* own id, both derived from a ceremony every signer contributed to and approved twice. A member who put a false parent on a proposal ends up with a key state for a room made from a key they helped make, which is what telling the truth would have got them. Both inputs are still read from this device's own database, so a proposer chooses nothing: naming some other ceremony this device holds a share for gets no path, and a session with no path signs as the threshold key. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index dd4b69b8..bf049bee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -58,10 +58,12 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * The exception is the group's *first* signature. A room's `GroupKeyState` is * now signed before the room it is about is created -- see - * `GroupKeyStateManager.propose` -- so that session runs in the NIP-17 room the - * ceremony ran in, where there is no MLS tree and a message goes out as one - * sealed gift wrap per member. [broadcast] is the only place that knows the - * difference; everything above it is the same protocol either way. + * `GroupKeyStateManager.propose` -- so that session runs wherever the ceremony + * ran: the members' NIP-17 room for a group's own key, where there is no MLS + * tree and a message goes out as one sealed gift wrap per member, and the + * parent's Marmot room for a subgroup's, where it is an ordinary group event. + * [broadcast] is the only place that knows the difference; everything above it + * is the same protocol either way. * * ### What it signs as * @@ -72,9 +74,9 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * holding a signed dialect therefore needs no lookup to check it: the author * they expect is the id of the room they found it in. * - * A session in a NIP-17 room signs as the room the ceremony's key derives -- - * the admin room that does not exist yet -- which is the same rule read - * forwards: what a group signs as is the room the signature belongs to. + * A session in a ceremony's own room signs as the room that ceremony's key + * derives -- the admin room that does not exist yet -- which is the same rule + * read forwards: what a group signs as is the room the signature belongs to. * * The path comes from the room, never from a proposal -- [signingPath] -- because * it decides which key the group signs as. @@ -1294,10 +1296,10 @@ object FrostSigningManager { /** * The key a room signs with, or null when it has none. * - * Signing usually runs in the admin room, which is not where the ceremony - * ran. A ceremony needs a NIP-17 group -- every member an equal admin, no - * MLS tree to be outside of -- while a group event needs an MLS one, so the - * two cannot be the same room. + * Signing usually runs in the admin room, which is never where the ceremony + * ran: the admin room's id is derived from the key the ceremony produces, so + * it cannot exist until afterwards. A group's own ceremony runs in the NIP-17 + * room its members share; a subgroup's runs in its parent's Marmot room. * * They are bound together by the room's [GroupKeyState]: the statement the * room opened with and the group signed, naming the ceremony behind it. That @@ -1316,6 +1318,15 @@ object FrostSigningManager { * until one matches; the second is a ceremony held in this very room, which * is not how the app wires things today but costs one lookup to keep honest. * + * That second one asks for the room's *own* ceremony, and the distinction is + * load-bearing now rather than pedantic. A parent's Marmot room hosts the + * ChillDKG of every subgroup it makes, so it holds completed ceremonies whose + * key belongs to a child; handing one back here would have the parent sign as + * its own subgroup, silently, for every member who holds no key-state row -- + * which is every member welcomed after the parent's own ceremony. A ceremony + * naming a parent is never the room's own key, and that is all it takes to + * tell them apart. + * * They are not only for rooms that predate the table. A room's key state is * signed before the room exists and filed as the room is created, so a * device that missed that session -- a member invited later, a reinstall -- @@ -1342,7 +1353,7 @@ object FrostSigningManager { }?.let { return it } return database.dkgSessionDao() - .getLatestSessionForChatRoom(chatRoomId) + .getLatestOwnSessionForChatRoom(chatRoomId) ?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } } @@ -1369,20 +1380,32 @@ object FrostSigningManager { * ### The room a group signs from before it has one * * One case cannot be self-checked, because there is nothing yet to check - * against: the NIP-17 room a ceremony ran in, signing the very statement - * that lets the group's Marmot room be created -- see - * `GroupKeyStateManager.propose`. A NIP-17 room's id is an aggregation of - * its members' keys, so it is not derived from anything and no path reaches - * it. What the group signs as there is the room it is about to make: the - * ceremony's key at the app's admin path. + * against: the room a ceremony ran in, signing the very statement that lets + * the room the ceremony's key derives be created -- see + * `GroupKeyStateManager.propose`. What the group signs as there is the room + * it is about to make: the ceremony's key at the app's admin path. * - * That is admitted only when the ceremony is *this room's own*, and the path - * is the constant rather than anything off the wire. Both inputs are read - * from this device's database, so a proposer still chooses nothing: naming + * Two rooms are that room. A group's own ceremony runs in the NIP-17 room its + * members share, whose id is an aggregation of their keys, so it is derived + * from nothing and no path reaches it. A *subgroup's* ceremony runs in the + * parent's Marmot room, which is derived from the parent's key and so reaches + * itself at some path -- just never from the child's, which is the key being + * signed under here. + * + * Each is admitted on the narrowest thing that identifies it, and the path is + * the constant rather than anything off the wire. Every input is read from + * this device's own database, so a proposer still chooses nothing: naming * some other ceremony this device holds a share for gets no path at all, and * a session with no path signs as the bare threshold key, which is not an * identity any room answers to. * + * `key.parentChatRoomId` is the unverified claim off a proposal, and admitting + * it here grants nothing. The signature it enables is by the child's key over + * the child's own id -- both derived from a ceremony every signer contributed + * to -- so a member who put a false parent on a proposal gets a key state for + * a room made from a key they helped make, which is what they would have got + * by telling the truth. + * * Null is not a failure. `completedKey` will find a key for a Marmot room * that was never derived from it -- the fallback kept for rooms the app no * longer makes -- and such a room has no key of its own to sign as, so it @@ -1408,7 +1431,10 @@ object FrostSigningManager { }.getOrDefault(false) }?.let { return it } - if (localChatRoom.chatRoom.mlsGroupState == null && key.chatRoomId == chatRoomId) { + val isCeremonyRoom = key.chatRoomId == chatRoomId + val isSubgroupCeremony = key.parentChatRoomId == chatRoomId + + if (isCeremonyRoom && (localChatRoom.chatRoom.mlsGroupState == null || isSubgroupCeremony)) { return SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH } From 51106874133734c88afb78b2108df68323853fe8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:27:57 +0200 Subject: [PATCH 4/6] feat(subgroups): run the child's ceremony in the parent's room, not beside it The switch. A subgroup's ChillDKG and its key state now happen in the parent's Marmot room; the sibling NIP-17 room derived from the child's admins is gone, and so is the rule that one admin set could hold one subgroup forever. This is what `docs/subgroups.md` deferred in "Why not the parent's Marmot room" and said to revisit. The reason to wait was `docs/mls-skipped-keys.md` -- a DKG cannot finish until every participant takes part, so one group event lost to the skipped-keys bug stalls it for everybody -- and that is being fixed. The two transports and the two guards this needs landed in the three commits before it. **`SelectSubgroupAdminsViewModel` stops standing up a room.** It opens the ritual in `loaded.parentRoom`, p-tagged to the picked admins, and hands back to the parent's transcript. That transcript is where the other admins were going to answer from anyway -- `observeCeremoniesAwaitingYou` and `observeProposalsAwaitingYou` are room-scoped and were already looking there -- so the coordinator now lands in the same place as everybody else rather than in a room only they knew was coming. The name and the admin set ride on the proposal and land on the session, which is what `1e7ddd84`'s two columns are for. `MarmotGroupName.of` still puts the `#` on at the two sites that use the name, and is still idempotent, so the certificate the parent signs and the room `MarmotGroupCreation` creates are called the same thing. **`SubgroupManager.ceremonyRoomIdFor` is deleted and `refuseCeremonyRoom` becomes `refuseSubgroup`.** There is no ceremony room to derive or to name a refusal after. The refusal it made -- "this group already has a subgroup run by exactly these members", forever -- narrows to a ceremony over those admins under that parent that is still *running*, on `getLatestSubgroupSessionFor`. That refusal is worth keeping for a reason the old one did not have. It is not that two subgroups over the same people are forbidden; it is that nobody can answer for two live ceremonies at once. Every admin would be asked twice, on two ladders, for two keys, one of which nobody will make a room from. A *finished* one means that subgroup exists, and asking for another is a legitimate ask that the derived room made impossible. **Three reads move from the room to the ceremony**, all of them in the ritual screen, and each would have been silently wrong in the parent's room: - `proposeBirthCertificate`'s `adminPublicKeys` -- the room's roster would ask the parent to certify itself as its own child; - `createAdminGroup`'s `members` -- it would welcome the whole parent into the subgroup; - the name both of those carry -- it would be the parent's. `DkgRitualUIState.ritualMembers` follows, so the progress ladder draws the ceremony's participants rather than a row per parent member, and does not report a ceremony waiting on people it was never with. Profiles still come off the room, which for a subgroup is the parent and holds every admin the ceremony can have. **Nothing new is offered on a Marmot room's detail screen.** The shared-key button is still gated on `mlsGroupState == null`, and the comment there now says why that is still right: offering a ceremony to a room is offering it a key of its own, and a Marmot room's id is derived from a key it already has. Its subgroups' ceremonies are reached from the transcript line that names the one they are about, which is the only thing that can say which. `SubgroupManagerJvmTest` follows the refusal. The two tests built on `ceremonyRoomIdFor` become tests of what actually distinguishes ceremonies now -- another subgroup's and the room's own do not block one, a live one over the same admins does, a finished one does not -- and the ordering test moves to `DkgSession.formatParticipants`, which is where "the same set however it was assembled" now has to hold. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseChatRepository.kt | 2 +- .../repository/DatabaseDkgRepository.kt | 8 +- .../compose/managers/SubgroupManager.kt | 84 ++++++------- .../compose/repository/ChatRepository.kt | 2 +- .../compose/repository/DkgRepository.kt | 42 ++++--- .../ui/composable/ChatRoomDetailScreen.kt | 22 ++-- .../ui/view/model/DkgRitualViewModel.kt | 43 ++++--- .../model/SelectSubgroupAdminsViewModel.kt | 83 ++++++------- .../compose/ui/view/state/DkgRitualUIState.kt | 43 +++++-- .../managers/SubgroupManagerJvmTest.kt | 115 +++++++++--------- 10 files changed, 245 insertions(+), 199 deletions(-) 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 c4f36b34..b931ad84 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 @@ -135,7 +135,7 @@ class DatabaseChatRepository( val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId) ?: return "Couldn't load this group." - SubgroupManager.refuseCeremonyRoom( + SubgroupManager.refuseSubgroup( database = database, parentRoom = parentRoom, adminPublicKeys = adminPublicKeys, 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 d436d3ef..e1674fc5 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 @@ -62,7 +62,9 @@ class DatabaseDkgRepository( userPublicKey: HexKey, nostrPrivateKey: ByteArray, threshold: Int, - parentChatRoomId: HexKey? + parentChatRoomId: HexKey?, + participantPublicKeys: Set?, + subject: String? ): DkgSession? = try { ChillDkgRitualManager.proposeRitual( database = database, @@ -70,7 +72,9 @@ class DatabaseDkgRepository( userPublicKey = userPublicKey, nostrPrivateKey = nostrPrivateKey, threshold = threshold, - parentChatRoomId = parentChatRoomId + parentChatRoomId = parentChatRoomId, + participantPublicKeys = participantPublicKeys, + subject = subject ) } catch (e: Throwable) { logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e) 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 c176d25a..79c3ab7a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.MantraDatabase -import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState @@ -24,6 +23,11 @@ import press.mantra.compose.nostr.subgroup.SubgroupParentage * 4. the Marmot room -> somebody creates C and welcomes the admins * ``` * + * **All four happen in the parent's room**, bar the last, which makes a room of + * its own. Steps 1 and 3 are held there because the child has nowhere else to be: + * its id is derived from the key step 1 produces, so it cannot exist until + * afterwards. Step 2 is there because the signature is the parent's. + * * None of that order is a policy choice. Step 2 needs `C`, which does not exist * until step 1 produces `K`; step 3 carries the certificate, so it needs step 2; * and step 4 is gated on the key state for the same reason @@ -67,23 +71,6 @@ object SubgroupManager { */ const val MINIMUM_ADMINS: Int = 3 - /** - * The NIP-17 room a ceremony for these admins would run in. - * - * Pure, and the same aggregate `NostrNip17Dao.createNip17ChatRoom` and the - * inbound path both derive -- which is what makes a ceremony room findable - * without being announced, and what makes two members starting the same - * subgroup land in one room rather than two. - * - * It is also where the one hard limit comes from. A NIP-17 room's id is a - * pure function of its members, so **one admin set gets one ceremony room, - * forever**: picking a set that already holds a completed ceremony would hand - * back that ceremony's key, and the "new" subgroup would be the old group - * under a new name. [refuseCeremonyRoom] is where that is caught. - */ - fun ceremonyRoomIdFor(adminPublicKeys: Set, coordinatorPublicKey: HexKey): String = - ChatRoom.deriveChatRoomId(adminPublicKeys + coordinatorPublicKey) - /** * Why these admins cannot hold a ceremony together, or null when they can. * @@ -94,7 +81,7 @@ object SubgroupManager { * The message is the user's, so each one says what to do rather than what is * wrong. */ - suspend fun refuseCeremonyRoom( + suspend fun refuseSubgroup( database: MantraDatabase, parentRoom: LocalChatRoom, adminPublicKeys: Set, @@ -130,8 +117,8 @@ object SubgroupManager { // group agreed, and is right on both sides from the first moment. // Named for the parent, because `adminPublicKeys` in this function is the // *subgroup's* picked admins and the two must never be confused -- the - // ceremony room is derived from the second, and deriving it from the - // first would silently make a different room. + // ceremony is opened over the second while running in a room governed by + // the first. val parentAdminPublicKeys = runCatching { parentRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys }.getOrNull() @@ -160,23 +147,30 @@ object SubgroupManager { // A subgroup *may* be the whole group. It is a logical division -- a group // deciding that some of its work belongs to a differently-keyed room -- // rather than a smaller membership, so "everybody" is a normal answer and - // was never this function's business to refuse. + // never this function's business to refuse. // - // What the old rule was standing in for is real and is checked below, - // precisely: the ceremony room is derived from its admins, so a subgroup - // over everybody lands in the room the group's own ceremony was held in. - // Sharing the room is fine; sharing a *ceremony* is not, and - // `DkgSession.parentChatRoomId` is what keeps them apart. + // The only thing left to refuse is a ceremony already under way for the + // same subgroup, and it is refused because a person cannot answer for two + // of them: every admin would be asked twice, on two ladders, for two keys, + // one of which nobody will ever make a room from. // - // Checking on set size was wrong twice over. It refused a legitimate - // subgroup, and it refused it on a proxy: a parent whose membership has - // changed since its own ceremony derives a different room, so the sizes - // could match with no collision at all, and could differ with one. - val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey) - val running = database.dkgSessionDao() - .getLatestSessionFor(ceremonyRoomId, parentChatRoomId) - if (running != null && running.stage != DkgRitualStage.FAILED) { - return "This group already has a subgroup run by exactly these members." + // A *finished* one is not refused. It means that subgroup was made, and a + // group is entitled to a second run by the same people -- which was + // impossible while the ceremony ran in a room derived from its admins, + // because asking again handed back the first ceremony's key and the "new" + // subgroup was the old one renamed. Moving the ceremony into the parent's + // room is what retired that, and this is the whole of what is left. + val running = database.dkgSessionDao().getLatestSubgroupSessionFor( + chatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty() + ) + if (running != null && + running.stage != DkgRitualStage.FAILED && + running.stage != DkgRitualStage.COMPLETE + ) { + return "A key ceremony for a subgroup run by exactly these members is " + + "already under way." } return null @@ -258,9 +252,9 @@ object SubgroupManager { * [subgroupChatRoomId], or null if this device holds none. * * **Two certificates for one child is a normal outcome, not a conflict.** Two - * parent admins can press the button on the same admin set: the second lands - * in the same ceremony room and gets the first's ceremony back, but both may - * go on to propose a certificate and both sessions can complete. + * parent admins can press the button on the same admin set: the second's + * `proposeRitual` folds into the ceremony the first opened, but both may go on + * to propose a certificate and both sessions can complete. * `GroupSignedEvent` is keyed on the event id, so the rows coexist -- and * because both say the same true thing about the same child, which one wins * does not matter. The `d` tag makes them replacements of each other rather @@ -286,9 +280,15 @@ object SubgroupManager { * Step 3: asks the child's quorum to sign its key state, carrying the * certificate. * - * Runs where the ceremony ran, because it has to: `GroupKeyStateManager.propose` - * and `FrostSigningManager.signingPath` both tie a key state to the room its - * ceremony was held in, and the room the state is *about* does not exist yet. + * Runs where the ceremony ran -- the parent's room -- because it has to: + * `GroupKeyStateManager.propose` and `FrostSigningManager.signingPath` both tie + * a key state to the room its ceremony was held in, and the room the state is + * *about* does not exist yet. + * + * Signed by the **child's** quorum for all that, not the parent's. The room is + * only the transport; who signs comes from the ceremony's host keys, and the + * key it signs with is the one that ceremony produced. A parent admin outside + * the subgroup reads the messages and holds no share to answer them with. * * Refuses without a certificate that passes `certifies`. Every device that * receives the state runs the same check and drops it when it fails, so 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 de8442ad..8fbfa95e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -95,7 +95,7 @@ interface ChatRepository { * * Read by the picker so a subgroup that could only fail is never offered, and * again on confirm, because a screen not drawing something is not a guard. - * See `SubgroupManager.refuseCeremonyRoom`. + * See `SubgroupManager.refuseSubgroup`. */ suspend fun refuseSubgroup( parentChatRoomId: String, 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 e60ec7f0..cb4da4eb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -25,9 +25,8 @@ interface DkgRepository { * [parentChatRoomId] is null, or that parent's subgroup when it is not. * Abandoned attempts are superseded by the newest. * - * A room can hold two: a subgroup whose admins are the whole group derives - * the room the group's own ceremony ran in. See - * `DkgSessionDao.getLatestSessionFor`. + * A room routinely holds several: a parent's room hosts the ceremony of every + * subgroup the group makes. See `DkgSessionDao.getLatestSessionFor`. */ fun observeLatestSessionForChatRoom( chatRoomId: String, @@ -67,18 +66,26 @@ interface DkgRepository { suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? /** - * Opens a ritual. Only meaningful for the room's creator. + * Opens a ritual in [localChatRoom], making this device the coordinator. * * [parentChatRoomId] says what the ceremony is for when it is for a subgroup. * It rides on the proposal and is believed by nobody -- see * `DkgSession.parentChatRoomId`. + * + * [participantPublicKeys] and [subject] are what a subgroup needs and a + * group's own ceremony does not: the room it runs in is the *parent's*, so + * the room can say neither who the ceremony is with nor what the room it + * makes is to be called. Both default to the room, which is the right answer + * for a group opening its own. */ suspend fun proposeRitual( localChatRoom: LocalChatRoom, userPublicKey: HexKey, nostrPrivateKey: ByteArray, threshold: Int, - parentChatRoomId: HexKey? = null + parentChatRoomId: HexKey? = null, + participantPublicKeys: Set? = null, + subject: String? = null ): DkgSession? /** What the ritual is waiting on this device's owner for, if anything. */ @@ -96,8 +103,8 @@ interface DkgRepository { * Asks the group to sign a statement of which ceremony's key its admin * room will sign with, before that room is created. * - * Called once by the ceremony's coordinator, in the NIP-17 room the ceremony - * ran in. What comes back is the signing session, not the state: the state + * Called once by the ceremony's coordinator, in the room the ceremony ran + * in. What comes back is the signing session, not the state: the state * exists when a quorum has signed, which is the point of proposing it rather * than announcing it. Null if the ritual has produced no key yet, or if this * room has no standing to propose one -- both of which mean there is nothing @@ -112,10 +119,11 @@ interface DkgRepository { /** * The signing sessions open in a room, newest first, with what each signs. * - * Read by the ritual screen to follow the one session a NIP-17 room ever - * holds: the group agreeing its key state. The items travel with the session - * because "which session is this" is answered by the kind of the event it is - * signing, not by the row. + * Read by the ritual screen to follow the session the group's key state is + * agreed in. The items travel with the session because "which session is + * this" is answered by the kind of the event it is signing, not by the row -- + * which is what lets the same reading work in a parent's room, where a + * subgroup's key state is one session among however many the room is running. */ fun observeSigningSessions(chatRoomId: String): Flow> @@ -149,10 +157,10 @@ interface DkgRepository { /** * Asks the parent's quorum to certify the subgroup this ceremony produced. * - * Runs in the **parent's** room, not the ceremony's, so what comes back is - * signed by the parent's key and authored by the parent's own room id -- the - * one thing a lineage can be checked against. See - * `SubgroupManager.proposeBirthCertificate`. + * Signed by the parent's key and authored by the parent's own room id -- the + * one thing a lineage can be checked against. The ceremony runs in that room + * too now, so this is no longer a change of venue, only of whose quorum is + * being asked. See `SubgroupManager.proposeBirthCertificate`. */ suspend fun proposeBirthCertificate( parentChatRoomId: String, @@ -210,7 +218,9 @@ interface DkgRepository { userPublicKey: HexKey, nostrPrivateKey: ByteArray, threshold: Int, - parentChatRoomId: HexKey? + parentChatRoomId: HexKey?, + participantPublicKeys: Set?, + subject: String? ): DkgSession? = null override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null 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 1edfffee..70340da0 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 @@ -590,9 +590,16 @@ fun ChatRoomDetailScreen( ) } - // A shared threshold key only means something where every - // member is an equal admin, which is the NIP-17 (robust) case. - // MLS rooms have one admin and no group key to share. + // Offering a ceremony to a room is offering it a key of + // its own, and only a NIP-17 room is in a position to + // want one: a Marmot room's id is derived from a key it + // already has, so a second ceremony there would produce + // a key the room could never answer to. + // + // A Marmot room does host ceremonies -- its subgroups' + // -- and those are reached from the transcript line that + // names the ceremony they are about, never from here, + // which has no way to say which. if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) { item { TextButton( @@ -619,11 +626,10 @@ fun ChatRoomDetailScreen( } } - // Every room, not only the one that holds the key. A - // ceremony needs a NIP-17 group, but a signing message is - // a marmot inner event -- see `FrostSigningManager.broadcast` - // -- so the room a group actually proposes in is the MLS - // one, which is the last place this should be missing from. + // 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 -- + // so that is the last place this should be missing from. // // The transcript carries a proposal past as it happens; // this is where a member goes to find one that has scrolled 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 f452df8c..791a0414 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 @@ -443,13 +443,17 @@ class DkgRitualViewModel( parentChatRoomId = parent, userPublicKey = activeUserPublicKey, session = session, + // The ceremony's participants, not the room's members. The + // ceremony runs in the parent's room, so the room's roster is + // everybody the subgroup is *not* -- putting it here would ask the + // parent to certify itself as its own child. adminPublicKeys = ChillDkgRitualManager - .memberPublicKeys(loaded.localChatRoom) + .participantsOf(session, loaded.localChatRoom) .toList(), - // The ceremony room was created with the name the coordinator - // typed, and the proposal carried it to everyone else -- so this - // is the same name every admin has been looking at. - name = loaded.localChatRoom.chatRoom.subject ?: "Subgroup" + // The name the coordinator typed, carried to every other admin on + // the proposal -- so this is the same name they have all been + // looking at, and the one the parent's quorum is about to sign. + name = session.subject ?: "Subgroup" ) isActionPending.value = false @@ -535,9 +539,10 @@ class DkgRitualViewModel( * Opens the group's admin room: a Marmot/MLS chat whose every member is an * admin, keyed on an id derived from the shared key. * - * The room the ceremony ran in is NIP-17, where nobody administers anything. - * This gives the same people a room where every one of them can act, which is - * the shape a group that has just made a t-of-n key is asking for. + * The room the ceremony ran in is somebody else's -- a NIP-17 room where + * nobody administers anything, or the parent whose subgroup this is. This + * gives the people who hold shares a room where every one of them can act, + * which is the shape a group that has just made a t-of-n key is asking for. * * Derived rather than random, unlike every other Marmot room. Every member's * device can compute the id from the ceremony they all took part in, so the @@ -546,10 +551,10 @@ class DkgRitualViewModel( * early to the existing room rather than minting a second one. * * Only once the group has signed what the room signs with. That statement is - * agreed in the NIP-17 room the ceremony ran in, before there is a Marmot - * room to hold it -- see `GroupKeyStateManager.propose` -- so by here it is - * a fact the room is created *knowing*, rather than the first thing it has - * to go and ask about. + * agreed in the room the ceremony ran in, before there is a room of its own + * to hold it -- see `GroupKeyStateManager.propose` -- so by here it is a fact + * the room is created *knowing*, rather than the first thing it has to go and + * ask about. */ fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) { if (isActionPending.value) return @@ -574,17 +579,19 @@ class DkgRitualViewModel( // Everyone the ceremony was run with, this device included. These are the // admins: the whole point of the room is that the members who hold shares of // the key can all act in it. - val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom) + // + // Off the ceremony rather than off the room, because for a subgroup the + // room is the parent's and its roster is the wrong set entirely. + val members = ChillDkgRitualManager.participantsOf(session, loaded.localChatRoom) // A subgroup is called what the coordinator called it; an admin room is // called after the group it administers, because it has no name of its // own to be given. Either way the bare name goes down and - // `MarmotGroupCreation` puts it under `MarmotGroupName`, so the NIP-17 - // room the ceremony ran in stays `Ekklesia` and the Marmot room it stands - // up is `#Ekklesia`. + // `MarmotGroupCreation` puts it under `MarmotGroupName`, so a NIP-17 room + // called `Ekklesia` stands up a Marmot room called `#Ekklesia`. val subgroupParent = loaded.parentChatRoomId - val name = loaded.localChatRoom.chatRoom.subject + val name = session.subject ?: if (subgroupParent != null) "Subgroup" else "Group" isActionPending.value = true @@ -597,7 +604,7 @@ class DkgRitualViewModel( purpose = if (subgroupParent != null) { "A subgroup of the group that certified it." } else { - "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}." + "Admins of ${session.subject ?: "the group"}." }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index 183578e1..ae4e93b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -20,9 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import press.mantra.compose.database.model.types.ChatRoomType -import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.managers.MarmotGroupCreation -import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute @@ -44,9 +42,10 @@ import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState * `quorumRange` and drops a proposal outside it, and the host-key approval gate is * where each of them agrees to the `t`-of-`n` they can now see. * - * Confirming opens the ceremony -- a NIP-17 room over the picked admins, and a - * ritual proposal in it -- and hands over to `DkgRitualViewModel`, which drives - * the remaining three steps. See `docs/subgroups.md`. + * Confirming opens the ceremony -- a ritual proposal in the **parent's** room, + * p-tagged to the picked admins -- and hands back to the parent's transcript, + * where every admin answers the ceremony from the same place. See + * `docs/subgroups.md`. */ class SelectSubgroupAdminsViewModel( val activeUserPublicKey: HexKey, @@ -191,12 +190,14 @@ class SelectSubgroupAdminsViewModel( } /** - * Opens the ceremony and hands over to the ritual screen. + * Opens the ceremony in the parent's room and hands back to its transcript. * - * The room is stood up first and the proposal is the first thing out of it, - * exactly as a robust group is created: standing up a NIP-17 room sends - * nothing to anybody, so the proposal is how the other admins hear of it at - * all -- which is why it carries the name and the parent. + * No sibling room is made. The ceremony runs where the parent's members + * already are: the proposal names the picked admins in its p-tags, which is + * the participant set every device derives `n` from, and a parent member who + * is not in it drops the proposal rather than joining. It carries the name + * and the parent for the same reason it always did -- the child has no room + * of its own to read either off until the ceremony has produced its key. */ fun confirm(onNavigateToRoute: (Route) -> Unit) { if (isActionPending.value) return @@ -223,45 +224,36 @@ class SelectSubgroupAdminsViewModel( return@launch } - // The ceremony room keeps the bare name. It is a NIP-17 room -- where - // the subgroup is made, not the subgroup -- and the `#` is what tells - // those two rows apart afterwards, so putting it on both would spend - // the mark to say nothing. + // The bare name goes on the ceremony, and the `#` is added where the + // name is used: `proposeBirthCertificate` normalises the name the + // parent signs and `MarmotGroupCreation` normalises the name the room + // carries, both reading it back off this session. + // `MarmotGroupName.of` is idempotent, so those are the same string -- + // which they have to be, or the room is not called what its parent + // certified. // - // The subgroup gets it twice over, and never from here: - // `proposeBirthCertificate` normalises the name the parent signs and - // `MarmotGroupCreation` normalises the name the room carries, both - // reading this subject. `MarmotGroupName.of` is idempotent, so those - // are the same string -- which they have to be, or the room is not - // called what its parent certified. - val ceremonyRoom = chatRepository.createNip17ChatRoom( - userPublicKey = activeUserPublicKey, - participantPublicKeys = selectedPublicKeys.toList(), - subject = name.value.trim(), - description = "Making ${MarmotGroupName.of(name.value)} a subgroup of " + - (loaded.parentRoom.chatRoom.subject ?: "this group") + "." - ) - - if (ceremonyRoom == null) { - isActionPending.value = false - uiState = SelectSubgroupAdminsUIState.Error( - "Couldn't start the subgroup's key ceremony. Please try again." - ) - return@launch - } - + // Carried on the session rather than on a room, because there is no + // longer a room to carry it: the ceremony is held in the parent's, + // and the parent's name is the one name a child must not take. val session = dkgRepository.proposeRitual( - localChatRoom = ceremonyRoom, + localChatRoom = loaded.parentRoom, userPublicKey = activeUserPublicKey, nostrPrivateKey = nostrPrivateKey.value.toByteArray(), threshold = threshold.value, - parentChatRoomId = parentChatRoomId + parentChatRoomId = parentChatRoomId, + // The admins, and only them. The room is the parent's, so the + // ceremony has to say who it is with -- this is the set that + // becomes the proposal's p-tags and every device's `n`. The + // coordinator is added by `proposeRitual`: opening a ceremony is + // being in it, and is not theirs to tick. + participantPublicKeys = selectedPublicKeys.toSet(), + subject = name.value.trim() ) isActionPending.value = false if (session == null) { - logger.e("Created ${ceremonyRoom.chatRoom.id} without a key ceremony") + logger.e("Could not open a subgroup ceremony in $parentChatRoomId") uiState = SelectSubgroupAdminsUIState.Error( "Couldn't start the subgroup's key ceremony. Please try again." ) @@ -269,11 +261,12 @@ class SelectSubgroupAdminsViewModel( } withContext(Dispatchers.Main) { - // The transcript, not the shared-key screen. The ceremony is - // already open and its first line is already in this room, so what - // the coordinator needs is to watch it and answer the requests as - // they arrive -- which is the same path every other member takes, - // through the ritual notices that name their own ceremony. + // The parent's transcript, not the shared-key screen. The ceremony + // is already open and its first line is already there, so what the + // coordinator needs is to watch it and answer the requests as they + // arrive -- which is now literally the same place every other + // admin answers from, through the ritual notices that name their + // own ceremony. // // Sending them to the shared-key screen instead was actively // harmful: that screen's job is to *offer* a ceremony to a room @@ -285,7 +278,7 @@ class SelectSubgroupAdminsViewModel( onNavigateToRoute( ChatRoomMessagingRoute( activeUserPublicKey = activeUserPublicKey, - chatRoomId = ceremonyRoom.chatRoom.id, + chatRoomId = parentChatRoomId, relayHint = null ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt index 52be6cb9..cf422002 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt @@ -102,20 +102,43 @@ sealed interface DkgRitualUIState { /** * Everyone the ceremony involves, as far as this device can tell. * - * The room's members, plus anyone who has actually taken part. The union - * matters because the two can disagree: `n` is fixed from the proposal's - * p-tags, while the room's rows are local and can drift, and a participant - * who has published a host key is in the ceremony whatever the room says. + * The ceremony's own participants, plus anyone who has actually taken + * part. The union matters because the two can disagree: `n` is fixed from + * the proposal's p-tags, and a participant who has published a host key is + * in the ceremony whatever any local row says. + * + * The **ceremony's** set, not the room's, and the difference is the whole + * ladder. A subgroup's ceremony runs in the parent's room, so the room's + * roster would draw a row per parent member and report the ceremony + * waiting on people it was never with. `participantsOf` falls back to the + * room for a ceremony recorded before that set was stored, which is right + * for those: they ran in a room whose members were exactly their + * participants. + * + * Profiles still come off the room, since the room is where the rows with + * names in them are -- for a subgroup that is the parent, which holds + * every admin the ceremony can have. */ val ritualMembers: List get() { - val known = localChatRoom.localParticipants.distinctBy { it.participant.participantPublicKey } - val knownKeys = known.map { it.participant.participantPublicKey }.toSet() + val roomMembers = localChatRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .associateBy { it.participant.participantPublicKey } - val strangers = (hostKeyParticipants + round1Participants + round2Participants) - .filterNot { it in knownKeys } - .map { LocalParticipant(Participant(participantPublicKey = it, chatRoomId = localChatRoom.chatRoom.id, relayHint = null), null) } + val participants = session + ?.let { ChillDkgRitualManager.participantsOf(it, localChatRoom) } + ?: roomMembers.keys - return known + strangers + return (participants + hostKeyParticipants + round1Participants + round2Participants) + .map { publicKey -> + roomMembers[publicKey] ?: LocalParticipant( + Participant( + participantPublicKey = publicKey, + chatRoomId = localChatRoom.chatRoom.id, + relayHint = null + ), + null + ) + } } /** 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 e1400675..5953e669 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -346,61 +346,73 @@ class SubgroupManagerJvmTest { } @Test - fun `the group's own ceremony does not block a subgroup in the same room`(): Unit = + fun `a ceremony in the parent's room for something else does not block one`(): Unit = runBlocking { - // The collision the size rule was standing in for, made concrete. A - // subgroup over everybody derives the room the group's own ceremony was - // held in -- so the room is shared, and only `parentChatRoomId` keeps - // the two ceremonies apart. Without that scoping `proposeRitual` would - // hand back the *group's* key and quietly make the child the parent. - val parent = parentWith(listOf(alice, bob)) - val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user) + // The parent's room hosts every ceremony the group ever runs, so "is + // this room already running one" stopped being a question worth + // asking the moment the ceremony moved here. Scoping on the room + // would refuse a group's second subgroup on the strength of its + // first, which is most of what the move was for. + val parent = parentWith(listOf(alice, bob, carol)) - seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user)) - db.dkgSessionDao().upsert(ownCeremony(ceremonyRoomId)) + db.dkgSessionDao().upsert( + subgroupCeremony(id = "other-admins", admins = setOf(alice, carol, user)) + ) + db.dkgSessionDao().upsert(ownCeremony(parentRoomId)) assertNull( refusal(parent, setOf(alice, bob)), - "the group's own ceremony is not this subgroup's", + "another subgroup's ceremony, and the room's own, are not this one", ) } @Test - fun `the same parent cannot have two subgroups over the same admins`(): Unit = runBlocking { + fun `a subgroup ceremony already under way over the same admins is refused`(): Unit = + runBlocking { + val parent = parentWith(listOf(alice, bob, carol)) + + assertNull(refusal(parent, setOf(alice, bob))) + + db.dkgSessionDao().upsert( + subgroupCeremony(id = "running", admins = setOf(alice, bob, user)) + ) + + // Not because two subgroups over the same people are forbidden -- + // they are not, since the move -- but because nobody can answer for + // two live ceremonies at once: every admin would be asked twice, on + // two ladders, for two keys, one of which nobody will ever make a + // room from. + assertNotNull(refusal(parent, setOf(alice, bob))) + + // A different admin set is a different subgroup, and untouched. + assertNull(refusal(parent, setOf(alice, carol))) + } + + @Test + fun `a finished subgroup does not stop a second over the same admins`(): Unit = runBlocking { + // The limitation that went away with the sibling room. A ceremony room + // derived from its admins meant one admin set got one subgroup forever: + // asking again handed back the first ceremony's key, so the "new" + // subgroup was the old one under a new name. In the parent's room a + // second ask is a second ceremony, with a key of its own. val parent = parentWith(listOf(alice, bob, carol)) - assertNull(refusal(parent, setOf(alice, bob))) - - // The one collision that survives, and it is degenerate: same parent, - // same admins. The ceremony room is derived from the admins and the - // ceremonies in it are told apart by their parent, so this pair has - // nothing left to distinguish them -- which is another way of saying it - // is one subgroup asked for twice. - val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user) - seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user)) db.dkgSessionDao().upsert( - ownCeremony(ceremonyRoomId).copy(id = "s1", parentChatRoomId = parentRoomId) + subgroupCeremony(id = "done", admins = setOf(alice, bob, user)) + .copy(stage = DkgRitualStage.COMPLETE) ) - assertNotNull(refusal(parent, setOf(alice, bob))) - - // And a different admin set is a different room, so it is untouched. - assertNull(refusal(parent, setOf(alice, carol))) + assertNull(refusal(parent, setOf(alice, bob))) } @Test - fun `the ceremony room is the same whichever way the admins are ordered`() { - // Every device has to land on the same room without being told, which is - // what makes a ceremony findable at all -- and what makes two members - // starting the same subgroup meet rather than fork. + fun `the stored participant set is the same whichever way the admins were picked`() { + // The coordinator assembles the set from a picker and every other device + // from the proposal's p-tags. They have to match as strings or the guard + // above finds nothing and every ask opens a ceremony. assertEquals( - SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), - SubgroupManager.ceremonyRoomIdFor(setOf(bob, alice), user) - ) - // And the coordinator is in it whether or not they were ticked. - assertEquals( - SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), - SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob, user), user) + DkgSession.formatParticipants(setOf(alice, bob, user)), + DkgSession.formatParticipants(setOf(user, bob, alice)) ) } @@ -488,24 +500,15 @@ class SubgroupManagerJvmTest { * The NIP-17 room a ceremony runs in, stood up the way the picker does it -- * room first, because `DkgSession.chatRoomId` is a foreign key onto it. */ - private suspend fun seedCeremonyRoom(expectedId: String, members: List) { - members.forEach { seedProfile(it) } - - val room = assertNotNull( - db.nostrNip17Dao().createNip17ChatRoom( - userPublicKey = user, - participantPublicKeys = members.filterNot { it == user }, - subject = "Translation team", - ) + /** A ceremony held in the parent's room to make a subgroup run by [admins]. */ + private fun subgroupCeremony(id: String, admins: Set) = + ownCeremony(parentRoomId).copy( + id = id, + parentChatRoomId = parentRoomId, + participantPublicKeys = DkgSession.formatParticipants(admins), + participantCount = admins.size, ) - assertEquals( - expectedId, - room.chatRoom.id, - "the derived ceremony room is the one createNip17ChatRoom lands on", - ) - } - /** A ceremony held in [chatRoomId] for no subgroup: the group's own. */ private fun ownCeremony(chatRoomId: String) = DkgSession( id = "own-ceremony", @@ -546,7 +549,7 @@ class SubgroupManagerJvmTest { SharedKeyDerivation.marmotGroupId(material.thresholdPublicKey.value.toHex(), path) private suspend fun refusal(parent: LocalChatRoom, admins: Set): String? = - SubgroupManager.refuseCeremonyRoom( + SubgroupManager.refuseSubgroup( database = db, parentRoom = parent, adminPublicKeys = admins, @@ -557,7 +560,7 @@ class SubgroupManagerJvmTest { * A parent this device could actually make a subgroup of: an admin of it, and * holding a share of its key. * - * Both are conditions of `refuseCeremonyRoom` before it looks at the picking + * Both are conditions of `refuseSubgroup` before it looks at the picking * at all, so a fixture without them tests only the first refusal. [canSign] * and [isAdmin] are here so the tests that are *about* those two can turn * each off on its own. @@ -574,7 +577,7 @@ class SubgroupManagerJvmTest { * that null. * * [canSign] and [isAdmin] are switchable so the tests about those two can turn - * each off on its own; both are conditions `refuseCeremonyRoom` checks before + * each off on its own; both are conditions `refuseSubgroup` checks before * it looks at the picking at all. */ private suspend fun parentWith( From 820e83e1816ddec5b38117f89314cdd76137a774 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:28:28 +0200 Subject: [PATCH 5/6] test(subgroups): the ceremony in somebody else's room, and the four ways it lies Six cases, and the ones worth writing are the ones where reading the room instead of the ceremony produces a plausible wrong answer rather than a crash. **The transport, and the p-tags on it.** The proposal is a `MarmotInnerEvent` and there are no gift wraps at all, and it p-tags the picked admins and nobody else. Both halves: the transport is the change, and the p-tags are what keeps the change from being a disaster, because dropping them the way `FrostSigningManager` correctly does in a Marmot room would enrol the whole parent in the child's permanent signing quorum. **The name.** On the proposal, on the session, once -- and the room keeps its own. There is nowhere else for a child's name to live now, and the parent's is the one name it must not take. **A parent member who was not picked drops the proposal**, opens no session, and publishes nothing. This is the property that makes the parent's room a safe place to hold a ceremony a subset of it is in: everyone can read the message, only the p-tagged are in the ceremony, and "can read" and "is in" have to stay different questions when the second one is permanent. **A picked admin joins, and not before approving.** The approval gate is unchanged by the move and has to stay that way -- a relay delivering a group event to a phone in a pocket must not enrol its owner in anything -- so this asserts nothing goes out until `approve`, then that what goes out is an inner event carrying the roster minus its own sender. **A host key that beat the proposal is replayed**, out of the inner-event store. The resume machinery is what makes the ritual safe to run anywhere, and it reads the backlog out of whichever store the room's transport writes to; looking in the wrong one is a stall with nothing to blame it on. **`completedKey` refuses to hand the parent its child's key.** The regression test for `ba8aa1e2`: a parent member with no key state and no share -- the position every member welcomed after the group's own ceremony is in, and the one that reaches the last fallback -- with a completed subgroup ceremony sitting in the room. Before the fix that fallback returned the child's key and the parent would have authored events as its own subgroup. It also asserts the child's *own* room still resolves it, by rederiving the id from the key, which is the check that actually binds a room to a key. Two databases, and the MLS group in each is real but not joint: messages are handed to the manager rather than encrypted between two trees. That is the right seam here -- what is under test is which store a message is queued in and which set it names, not whether quartz can encrypt it -- and `SignedGroupKeyStateTest` is where a session runs end to end. 1136 tests pass, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../SubgroupCeremonyInParentRoomTest.kt | 567 ++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt new file mode 100644 index 00000000..c30df4c8 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt @@ -0,0 +1,567 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MarmotInnerEvent +import press.mantra.compose.database.model.NostrEvent +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.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.dkg.DkgRitualEvents + +/** + * A subgroup's key ceremony, held in the room of the group making it. + * + * It used to be held in a sibling NIP-17 room whose id was an aggregate of the + * child's admins. That bought a transport tolerant of reordering, and it cost a + * room per subgroup and a hard limit -- one admin set could hold one subgroup + * ever, because asking again derived the same room and handed back the same key. + * Now the ceremony runs where the parent already is, and everything the sibling + * room used to answer has to be answered by the session instead. + * + * Three of those answers are what this covers, because each one silently reads + * the wrong thing if it comes off the room: + * + * - **who the ceremony is with** is a subset of the parent's members, so the + * room's roster names every person the subgroup is not; + * - **what the room it makes is called** is not the parent's name, which is the + * one name a child must not take; + * - **what key the parent signs with** is not the child's, which is now a + * completed ceremony sitting in the parent's own room. + * + * The MLS group here is real but not joint: messages are handed to the manager + * directly rather than encrypted and decrypted between two trees. That is the + * right seam for these -- what is under test is which store a message is queued + * in and which set it names, not whether quartz can encrypt it -- and + * `SignedGroupKeyStateTest` is where a session is run end to end. + */ +class SubgroupCeremonyInParentRoomTest { + + private val parent: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + /** A second device's database, so a proposal can be received rather than made. */ + private val receiver: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() { + parent.close() + receiver.close() + } + + /** The parent's own key, which its room's id is derived from. */ + private val parentMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = 4, + threshold = 3 + ) + + private val parentRoomId = + SharedKeyDerivation.marmotGroupId(parentMaterial.thresholdPublicKey.value.toHex()) + + // Real keys throughout: a host key is derived from a nostr secret, and the + // participant set is sorted by hex, so filler exercises neither. + private val coordinator = KeyPair() + private val user = coordinator.pubKey.toHexKey() + + private val aliceKeys = KeyPair() + private val alice = aliceKeys.pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + + /** A parent member who is not picked for the subgroup. */ + private val mallory = KeyPair() + private val malloryPublicKey = mallory.pubKey.toHexKey() + + private val members = listOf(alice, bob, malloryPublicKey) + + /** The subgroup's admins: three of the parent's four members. */ + private val admins = setOf(alice, bob) + + private val quorum = 2 + + private suspend fun openCeremony(room: LocalChatRoom) = ChillDkgRitualManager.proposeRitual( + database = parent, + localChatRoom = room, + userPublicKey = user, + nostrPrivateKey = coordinator.privKey!!, + threshold = quorum, + parentChatRoomId = parentRoomId, + participantPublicKeys = admins, + subject = "Translation" + ) + + private suspend fun innerEvents(database: MantraDatabase) = database.marmotInnerEventDao() + .getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList()) + + /** The rumor as the manager queued it, which is what the other side receives. */ + private fun MarmotInnerEvent.asEvent() = Event( + id = id, + pubKey = publicKey, + createdAt = createdAt.epochSeconds, + kind = kind, + tags = tags, + content = content, + sig = "" + ) + + /** + * The ceremony goes out as group events, and names only the admins. + * + * Both halves matter. The transport is the change; the p-tags are what stops + * the change being a disaster, because they are the participant set every + * device derives `n` from -- so leaving them off in a Marmot room, the way + * `FrostSigningManager` correctly does, would enrol the whole parent in the + * child's signing quorum. + */ + @Test + fun `a subgroup's ceremony rides the parent's transport and names its own admins`(): Unit = + runBlocking { + val room = parentRoom() + + assertTrue(innerEvents(parent).isEmpty(), "nothing has gone out yet") + + val session = openCeremony(room) + + assertTrue( + parent.giftWrapPayloadDao() + .getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList()) + .isEmpty(), + "a ceremony in a Marmot room queues no gift wraps", + ) + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }, + "the proposal is an inner event for the parent's room", + ) + assertEquals(user, proposal.publicKey) + assertEquals(parentRoomId, proposal.chatRoomId) + + // The p-tags are the roster, not an address list -- the sender is in + // the set without tagging themselves. + assertEquals( + admins, + proposal.tags.taggedUsers().map { it.pubKey }.toSet(), + "the proposal names the picked admins and nobody else", + ) + assertEquals( + admins + user, + session.participantPublicKeySet(), + "and the session records the same set, the coordinator included", + ) + assertEquals(3, session.participantCount) + assertEquals(parentRoomId, session.parentChatRoomId) + + // The coordinator's host key goes out on the same transport, since + // opening a ceremony is the act of agreeing to be in it. + assertEquals( + 1, + innerEvents(parent).count { it.kind == DkgRitualEvents.HOST_KEY }, + "the coordinator joins the ceremony it opened", + ) + assertNotNull(session.hostKeyApprovedAt) + } + + /** + * The child's name has nowhere else to live. + * + * It used to be the ceremony room's subject: a NIP-17 room called `Translation` + * whose proposal carried the name to everyone else, and which every later step + * read back off the room. The parent's room is called something else, and the + * one name a child must not take is its parent's. + */ + @Test + fun `the subgroup's name travels on the proposal and lands on the session`(): Unit = runBlocking { + val room = parentRoom() + val session = openCeremony(room) + + assertEquals("Translation", session.subject) + assertEquals("Ekklesia", room.chatRoom.subject, "the room keeps its own name") + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + assertEquals("Translation", DkgRitualEvents.parseSubject(proposal.tags)) + + // Only on the proposal. Every later message belongs to a session the + // receiver already has this on, and a later say in what a ceremony makes + // would be a second answer to a settled question. + innerEvents(parent).filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach { + assertNull(DkgRitualEvents.parseSubject(it.tags), "kind ${it.kind} repeats the name") + } + } + + /** + * Everybody in the parent's room can read the proposal. Only the admins are in + * it. + * + * This is the price of the parent's transport and the thing that makes it + * safe: an MLS message reaches the whole tree, so the ceremony has to say who + * it is with, and a member who is not named drops it rather than joining. A + * member who published a host key would be in the group's permanent signing + * quorum, so "reads the message" and "is in the ceremony" must not be the same + * question. + */ + @Test + fun `a parent member who was not picked drops the proposal`(): Unit = runBlocking { + val room = parentRoom() + val session = openCeremony(room) + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + + receiverRoom() + ChillDkgRitualManager.processRitualPayload( + database = receiver, + localChatRoom = assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId)), + innerEvent = proposal.asEvent(), + userPublicKey = malloryPublicKey, + nostrPrivateKey = mallory.privKey!! + ) + + assertNull( + receiver.dkgSessionDao().getSessionById(session.id), + "a member outside the p-tags opens no session", + ) + assertTrue( + innerEvents(receiver).isEmpty(), + "and publishes nothing, so their host key never joins the quorum", + ) + } + + /** + * A picked admin joins, on the same transport, and only when they say so. + * + * The approval gate is unchanged by the move and has to stay that way: a + * relay delivering a group event to a phone in a pocket must not enrol its + * owner in a permanent signing quorum. + */ + @Test + fun `a picked admin joins the ceremony, and not before approving`(): Unit = runBlocking { + val room = parentRoom() + openCeremony(room) + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + + val theirRoom = receiverRoom() + ChillDkgRitualManager.processRitualPayload( + database = receiver, + localChatRoom = theirRoom, + innerEvent = proposal.asEvent(), + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!! + ) + + val joined = assertNotNull( + receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId), + "a picked admin records the ceremony", + ) + assertEquals(3, joined.participantCount) + assertEquals(quorum, joined.threshold) + assertEquals(parentRoomId, joined.parentChatRoomId) + assertEquals("Translation", joined.subject) + assertEquals(admins + user, joined.participantPublicKeySet()) + + assertTrue(innerEvents(receiver).isEmpty(), "nothing goes out before they agree") + assertEquals( + DkgApprovalStep.HOST_KEY, + ChillDkgRitualManager.pendingApproval(receiver, joined), + ) + + ChillDkgRitualManager.approve( + database = receiver, + localChatRoom = theirRoom, + sessionId = joined.id, + step = DkgApprovalStep.HOST_KEY, + nostrPrivateKey = aliceKeys.privKey!! + ) + + val published = innerEvents(receiver) + assertEquals(1, published.size, "one message, and it is an inner event") + assertEquals(DkgRitualEvents.HOST_KEY, published.first().kind) + assertEquals( + admins + user - alice, + published.first().tags.taggedUsers().map { it.pubKey }.toSet(), + "a later message carries the roster too, minus its own sender", + ) + } + + /** + * A message that beat the proposal is replayed out of the store its transport + * writes to. + * + * The ritual's own resume machinery is what makes it safe to run anywhere: + * every payload is stored as it decrypts and read back once there is a session + * to file it under. That lookup has to make the same reading of the room that + * `broadcast` does -- a ceremony in a Marmot room has its backlog in the inner + * events and none at all in the gift wraps, and looking in the wrong one is a + * stall with nothing to blame it on. + */ + @Test + fun `a host key that arrived before the proposal is replayed`(): Unit = runBlocking { + val room = parentRoom() + val session = openCeremony(room) + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + + val theirRoom = receiverRoom() + + // Bob's host key, stored by the inbound path and dropped at the time for + // want of a session to file it under. + val early = MarmotInnerEvent( + id = "ee".repeat(32), + publicKey = bob, + kind = DkgRitualEvents.HOST_KEY, + createdAt = Instant.fromEpochSeconds(1), + tags = DkgRitualEvents.assembleTags(sessionId = session.id), + content = "02".repeat(33), + chatRoomId = parentRoomId + ) + receiver.marmotInnerEventDao().upsert(early) + + ChillDkgRitualManager.processRitualPayload( + database = receiver, + localChatRoom = theirRoom, + innerEvent = proposal.asEvent(), + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!! + ) + + assertNotNull( + receiver.dkgSessionDao().getMessage(session.id, DkgRitualEvents.HOST_KEY, bob), + "the message that lost the race is not lost", + ) + } + + /** + * The parent does not start signing as its own child. + * + * `completedKey`'s last fallback is a ceremony held in this very room, and it + * is load-bearing: it is what every member welcomed after the group's own + * ceremony falls back to, since they hold no key state and no share. The + * parent's room now holds a completed ceremony whose key belongs to the child, + * and handing that back would have the parent author events as its subgroup, + * silently. + */ + @Test + fun `a subgroup's completed ceremony is not the parent's key`(): Unit = runBlocking { + // A member with no key state and no share of the parent's key: exactly the + // position that reaches the fallback. + parentRoom(withOwnKey = false) + + val childMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2") + ), + nParticipants = 3, + threshold = 2 + ) + + parent.dkgSessionDao().upsert( + DkgSession( + id = "child-ceremony", + chatRoomId = parentRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = quorum, + participantCount = 3, + participantPublicKeys = DkgSession.formatParticipants(admins + user), + subject = "Translation", + stage = DkgRitualStage.COMPLETE, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(), + secretShare = "cc".repeat(32), + parentChatRoomId = parentRoomId + ) + ) + + assertNull( + FrostSigningManager.completedKey(parent, parentRoomId), + "a ceremony run to make a subgroup is never the room's own key", + ) + + // And the child's own room still resolves it, by rederiving the id from + // the key -- which is the check that actually binds a room to a key. + assertEquals( + "child-ceremony", + FrostSigningManager.completedKey( + parent, + SharedKeyDerivation.marmotGroupId( + childMaterial.thresholdPublicKey.value.toHex() + ) + )?.id, + ) + } + + /** + * The parent's admin room, as the device that made it holds it. + * + * [withOwnKey] seeds the group's own completed ceremony, which is how a real + * admin room resolves the key it signs with. Turned off for the member + * welcomed later, who holds neither a share nor a key state. + */ + private suspend fun parentRoom(withOwnKey: Boolean = true): LocalChatRoom { + (members + user).forEach { seedProfile(parent, it) } + + val metadata = MarmotGroupData( + nostrGroupId = parentRoomId, + name = "Ekklesia", + description = SharedKeyDerivation.describe("Admins of Ekklesia."), + adminPubkeys = members + user, + relays = Relays.DefaultDMRelayList.map { it.url } + ) + val mlsGroup = MlsGroup.create( + KeyPair().pubKey, + Ed25519.generateKeyPair().privateKey, + listOf(metadata.toExtension()) + ) + + parent.chatRoomDao().upsert( + ChatRoom( + id = parentRoomId, + userPublicKey = user, + subject = "Ekklesia", + description = metadata.description, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + ) + ) + parent.participantDao().upsert( + (members + user).map { + Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null) + } + ) + + if (withOwnKey) { + parent.dkgSessionDao().upsert( + DkgSession( + id = "parent-ceremony", + // Filed under the parent's own room. In life it ran in the + // NIP-17 room the group's members shared, and either way it + // claims no parent -- which is the only thing that has to be + // true for it to be found as the room's own key. + chatRoomId = parentRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = 3, + participantCount = 4, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + secretShare = "cc".repeat(32), + ) + ) + (members + user).forEachIndexed { index, member -> + parent.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = "parent-ceremony", + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = "0${index + 1}".repeat(33) + ) + ) + } + } + + return assertNotNull(parent.chatRoomDao().findChatRoomById(parentRoomId)) + } + + /** + * The same room on somebody else's device. + * + * Its own MLS group rather than a copy of the coordinator's: nothing here is + * encrypted between the two, and a shared state object would make the two + * databases one device wearing two hats. + */ + private suspend fun receiverRoom(): LocalChatRoom { + (members + user).forEach { seedProfile(receiver, it) } + + val metadata = MarmotGroupData( + nostrGroupId = parentRoomId, + name = "Ekklesia", + description = SharedKeyDerivation.describe("Admins of Ekklesia."), + adminPubkeys = members + user, + relays = Relays.DefaultDMRelayList.map { it.url } + ) + val mlsGroup = MlsGroup.create( + KeyPair().pubKey, + Ed25519.generateKeyPair().privateKey, + listOf(metadata.toExtension()) + ) + + receiver.chatRoomDao().upsert( + ChatRoom( + id = parentRoomId, + userPublicKey = alice, + subject = "Ekklesia", + description = metadata.description, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + ) + ) + receiver.participantDao().upsert( + (members + user).map { + Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null) + } + ) + + return assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId)) + } + + private suspend fun seedProfile(database: MantraDatabase, publicKey: String) { + if (database.profileDao().getProfileByPublicKey(publicKey) != null) return + + val nostrEventId = publicKey.take(63) + "f" + database.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + database.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } +} From 0be31803f2ef2dab36c0d5aeaeb736762be28f44 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:28:28 +0200 Subject: [PATCH 6/6] docs: record phase 10, and the deferred decision it carries out `docs/subgroups.md` was written as ten phases of reasoning kept in the order they were argued, and phase 4 spent forty lines on why the child's ceremony was *not* held in the parent's Marmot room -- explicitly so the decision would not be re-litigated without its price attached. That section is now a shopping list that has been carried out, so it keeps its argument and gains a pointer forward, and the three costs it enumerated are checked off one by one in a new phase 10. The parts of the note that state the old arrangement as present-tense fact are updated rather than annotated: the three-ceremonies table now reads one room, three ceremonies, two quorums, and says the thing that needs saying twice -- an MLS message reaches the whole tree, so a ceremony in the parent's room has to name who it is with. Phase 10 itself is written the way the others are, around what fails silently: - `DkgSession.chatRoomId` stopped identifying a ceremony, and the place that matters is `completedKey`'s last fallback, which every member welcomed after a group's own ceremony lands on; - `signingPath` had to admit a Marmot room, which widens the one function whose contract is that a path never comes off a proposal; - the p-tags had to stay on both transports, which is the opposite of what `FrostSigningManager` correctly does. The two sections that argued the old collision -- "The collision this buys" and "Why a subgroup cannot be the whole group was withdrawn" -- keep their reasoning and gain the end of it: `(room, parent)` stopped telling two subgroups of one parent apart, so the lookup moved to `(room, parent, admins)`, and the permanent half of the refusal disappeared with the derived room. The limitation and the appendix entry are struck through rather than deleted, since what they were weighing is why the phase exists. `docs/shared-key-ceremony.md` no longer says a ceremony runs over a NIP-17 chat. The participant set is the proposal's p-tags on both transports, and that distinction is the whole reason it is stated that way rather than as "the group". `docs/mls-skipped-keys.md` keeps `proposeRitual` in its table of reliable triggers and now says what changed about it: it reached that table on gift wraps, where the bug does not apply, and a subgroup's ceremony now rides group events. It is the entry with the worst consequence -- a ChillDKG cannot finish until every participant takes part, so one lost round-1 message stalls it permanently for everybody rather than costing one member a line of chat. That is the thing the quartz fix in that note is now load-bearing for. Co-Authored-By: Claude Opus 5 --- docs/mls-skipped-keys.md | 6 + docs/shared-key-ceremony.md | 16 ++- docs/subgroups.md | 213 ++++++++++++++++++++++++++++-------- 3 files changed, 187 insertions(+), 48 deletions(-) diff --git a/docs/mls-skipped-keys.md b/docs/mls-skipped-keys.md index fbf61175..597eff2a 100644 --- a/docs/mls-skipped-keys.md +++ b/docs/mls-skipped-keys.md @@ -109,6 +109,12 @@ Several flows publish in bursts, and each of them is a reliable trigger: twenty-one events at once, and only the ones that happen to arrive in ascending generation order survive. +`proposeRitual` is the one with the worst *consequence*. It reached this table on +gift wraps, where the bug does not apply; since `docs/subgroups.md` Phase 10 a +subgroup's ceremony rides group events, and a ChillDKG cannot finish until every +participant has taken part — so one lost round-1 message stalls it permanently for +everybody, rather than costing one member a line of chat. + ## The fix, in quartz Carry the skipped keys through `saveState`/`restore` alongside the ratchet diff --git a/docs/shared-key-ceremony.md b/docs/shared-key-ceremony.md index 65657faa..bb1b79a2 100644 --- a/docs/shared-key-ceremony.md +++ b/docs/shared-key-ceremony.md @@ -1,15 +1,21 @@ # The shared key ceremony -A group creates a `t`-of-`n` FROST key by running ChillDKG over its NIP-17 chat. -No trusted dealer, no single device ever holding the whole key. +A group creates a `t`-of-`n` FROST key by running ChillDKG over its own chat. No +trusted dealer, no single device ever holding the whole key. Implemented in `ChillDkgRitualManager`, on `fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG`. ## Shape -The group is the participant set, the member who opens the ceremony coordinates -it, and every protocol message travels as a gift-wrapped rumor on the same NIP-17 -pipeline chat messages already use — so there is no second transport to operate. +The participant set is who the proposal `p`-tags, the member who opens the +ceremony coordinates it, and every protocol message travels on a pipeline chat +messages already use — a gift-wrapped rumor in a NIP-17 room, an MLS application +message in a Marmot one — so there is no second transport to operate. + +A group's own ceremony runs in the NIP-17 room its members share, and there the +participant set and the room's membership are the same thing. A **subgroup's** +runs in its parent's Marmot room over a subset of it, which is why the set is the +p-tags rather than the room: see [subgroups.md](./subgroups.md). The coordinator is a participant too, and ChillDKG treats it as untrusted: it relays and aggregates but cannot learn secrets or bias the key. Being the room's diff --git a/docs/subgroups.md b/docs/subgroups.md index 15565c75..1ffdf8ad 100644 --- a/docs/subgroups.md +++ b/docs/subgroups.md @@ -9,9 +9,12 @@ Read [shared-key-derivation.md](./shared-key-derivation.md) and built on the property they state — that a room's id *is* the key it signs with — and on the ceremony they describe, which a subgroup runs again from scratch. -**Built**, phases 1-9, one commit each. The phases 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 +**Built**, phases 1-9, one commit each, and then **Phase 10 moved the child's +ceremony into the parent's Marmot room** -- the thing [Phase 4](#why-not-the-parents-marmot-room) +deferred and said to revisit. Phases 1-9 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 Phase 10 overtook one of them the section says +so and points forward. Where the implementation chose differently the section says so, and it did so six times worth reading: | what the plan said | what it turned out to be | @@ -116,14 +119,27 @@ opens the screen and can act, not only to the member who started. | step | runs in | signed by | transport | |---|---|---|---| -| ChillDKG | a NIP-17 room over the child's admins | n/a — every participant | gift wraps | +| ChillDKG | the **parent** Marmot room, over the child's admins | n/a — every participant | MLS group events | | birth certificate | the **parent** Marmot room | the parent's quorum, as the parent's key | MLS group events | -| key state | the child's NIP-17 ceremony room | the **child's** quorum, as the child's key | gift wraps | +| key state | the **parent** Marmot room | the **child's** quorum, as the child's key | MLS group events | -The middle row is the only new shape, and it is not new machinery: -`FrostSigningManager` already runs on both transports and already gates each -signer's approval behind the existing proposal UI. What is new is a kind for it -to carry and a reason for the parent's admins to say yes. +One room, three ceremonies, two quorums. The middle row was the only new shape +and it is not new machinery: `FrostSigningManager` already runs on both +transports and already gates each signer's approval behind the existing proposal +UI. What is new is a kind for it to carry and a reason for the parent's admins to +say yes. + +The first and third rows read like that since [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room). +They used to run in a sibling NIP-17 room whose id was an aggregate of the child's +admins, and the reasons for moving them -- and the two ways it can silently go +wrong -- are there. + +The parent's room hosting a ceremony its own members are not all in is the one +thing that needs saying twice. An MLS message reaches the whole tree, so the +ceremony has to name who it is with: the proposal's p-tags are the participant +set, and a parent member outside them drops the proposal rather than joining. +"Can read the message" and "is in the ceremony" are different questions, and a +member who published a host key would be in the child's signing quorum for good. ## Informed consent, and what the parent's admins are actually signing @@ -353,6 +369,11 @@ state's. ## Phase 4 — where the subgroup's ceremony runs +> **Overtaken by [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room).** +> The ceremony now runs in the parent's Marmot room. Everything below is what was +> built first and why; the two tags it adds are unchanged and still travel on the +> proposal, and the collision it argues about is the thing Phase 10 retired. + The child's ChillDKG needs a room whose participant set is exactly the child's admins, and whose transport is gift wraps. There is already one: `chatRepository.createNip17ChatRoom` over the selected admins plus the @@ -391,6 +412,11 @@ ever reaches the creator's own device. ### The collision this buys, and it is real +> Retired by [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room): +> there is no member-derived ceremony room left to collide. What survives is a +> narrower refusal -- a ceremony for the same admins under the same parent that is +> still *running* -- and the reasoning below is why that one is worth keeping. + A NIP-17 room's id is a pure function of its members, so **one admin set gets one ceremony room, forever**. Two consequences, and both need a guard rather than a comment: @@ -411,6 +437,11 @@ admins. See [that phase](#phase-8--the-refusals). ### Why not the parent's Marmot room +> **It landed** — see [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room), +> which is this section's shopping list carried out. Kept as written because the +> three mechanical costs it enumerates are exactly the three that had to be paid, +> and two of them fail silently if they are not. + The obvious alternative is to hold all three steps in the parent room: no sibling room, no member-derived id, and the collision above disappears. It is a better design and it is not available yet. What follows is why, because "we did not" @@ -492,10 +523,10 @@ reimplement them. ```kotlin object SubgroupManager { /** The NIP-17 room a ceremony for these admins would run in. Pure. */ - fun ceremonyRoomIdFor(adminPublicKeys: Set, coordinator: HexKey): String + fun ceremonyRoomIdFor(adminPublicKeys: Set, coordinator: HexKey): String // deleted in Phase 10 /** Step 1: stand up the ceremony room and open the ritual in it. */ - suspend fun openCeremony(...): DkgSession + suspend fun openCeremony(...): DkgSession // never built /** Step 2: ask the parent's quorum to certify the child. Runs in the parent room. */ suspend fun proposeBirthCertificate( @@ -547,9 +578,9 @@ signed statement, and every device in the parent room already has them — followed the session, not only on the signers'. **Two certificates for one child is a normal outcome, not a conflict.** Two parent -admins can press the button on the same admin set: the second lands in the same -NIP-17 ceremony room and gets the first's ceremony back, but both may go on to -propose a certificate, and both sessions can complete. `GroupSignedEvent` is keyed +admins can press the button on the same admin set: the second's `proposeRitual` +folds into the ceremony the first opened, but both may go on to propose a +certificate, and both sessions can complete. `GroupSignedEvent` is keyed on the event id, so the rows coexist. `certificateFor` therefore takes the **newest** of those that pass `certifies`, the way `GroupKeyStateManager.signedAmong` already picks the newest state — and because both certificates say the same true @@ -706,11 +737,11 @@ certificate so the parent's admins approve something legible. `MarmotGroupName.of` puts the `#` on it. The field shows the prefix beside what is typed rather than in it, so the caret does not move under somebody mid-word, and -the ceremony room keeps the bare name — it is a NIP-17 room, and the mark is what -tells it apart from the subgroup it stands up. The subgroup's own name is derived -from that subject twice, by two unrelated callers: `proposeBirthCertificate` -normalises the name the parent signs, `MarmotGroupCreation` normalises the name the -room carries. Those two have to be the same string or the subgroup is not called +the bare name is what goes down — since Phase 10 onto `DkgSession.subject`, which +is where a ceremony held in somebody else's room has to keep it. The subgroup's +own name is derived from that twice, by two unrelated callers: +`proposeBirthCertificate` normalises the name the parent signs, +`MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string or the subgroup is not called what its parent certified, which is what the rule being idempotent buys. ### Key packages, checked here rather than discovered at step 4 @@ -780,9 +811,12 @@ rendered only when a parent is present, and it is where the coordinator proposes the certificate and watches the parent's quorum answer. The approvals themselves need **no new UI**. The certificate is a -`FrostSigningEvents.PROPOSAL` in the parent room and the key state one in the -ceremony room; `ProposalListScreen` and `FrostSigningScreen` already show and -approve both, on both transports. +`FrostSigningEvents.PROPOSAL` in the parent room and so, since Phase 10, is the +key state; `ProposalListScreen` and `FrostSigningScreen` already show and approve +both. Which quorum each asks is not the room's business either way — a proposal +names the ceremony it signs under, and `signerIdOf` drops one for a ceremony this +device was not in, so a parent admin outside the subgroup never sees the child's +key state as theirs to answer. ## Phase 8 — the refusals @@ -798,7 +832,7 @@ a room nobody can replace. | an empty or blank name | Phase 6 has nothing to put in `MarmotGroupData.name`, and the parent's admins would be certifying an unnamed hash | | a selected member with no unconsumed key package | they cannot be welcomed into the room at step 4, and finding that out at step 4 wastes three ceremonies. Checked at the picker and again at confirm | | ~~the selection is not a proper subset of the parent's members~~ | **withdrawn.** A subgroup is a logical division, not a smaller membership, so "everybody" is a normal answer. What this was standing in for is the row below, checked precisely rather than by set size | -| the ceremony room already holds a non-`FAILED` `DkgSession` **for this same parent** | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id. Scoped by `DkgSession.parentChatRoomId`, so a room may hold the group's own ceremony *and* a subgroup's | +| a ceremony for **this parent over these same admins** is still running | every admin would be asked twice, on two ladders, for two keys, one of which nobody will make a room from. Matched on `DkgSession.participantPublicKeys` since Phase 10, and only while the ceremony is live: a *finished* one means that subgroup was made, and a second run by the same people is a legitimate ask | | this device holds no share of the parent's key | it cannot open the certificate session; `proposeSigningBatch` throws, and throwing at the button is not a UI | | this device is not an admin of the parent | a non-admin proposing the parent's signature is a proposal the parent's admins have to decline by hand | | the parent has no signed key state and no resolvable key | there is nothing for the certificate to be signed with | @@ -830,6 +864,10 @@ forbid the selection. A room may now hold two ceremonies, and everything below that lookup already keyed off the session id, so nothing else had to learn about the second one. +Phase 10 finished the thought. A parent's room hosts *every* subgroup ceremony +the group ever runs, so `(room, parent)` stopped telling two of them apart and the +lookup moved to `(room, parent, admins)` — `getLatestSubgroupSessionFor`. + One collision survives, and it is degenerate: the same parent, over the same admins, twice. Those two have nothing left to distinguish them, which is another way of saying they are one subgroup asked for twice, and that is what the message @@ -857,11 +895,105 @@ Two that need a database, in jvmTest, where `secp256k1` loads and Room runs: mutation of it. - `SubgroupDaoJvmTest` — schema 17 round-trips the four columns, and `subgroupsOf` returns a certified child whose room does not exist locally. +- `SubgroupCeremonyInParentRoomTest` (Phase 10) — the ceremony queued as inner + events rather than gift wraps, the p-tags naming only the picked admins, a + parent member outside them opening no session, a backlog replayed out of the + inner-event store, and `completedKey` refusing to hand the parent its child's + key. One end-to-end, and it is the one that would have caught the collision in Phase 4: two ceremonies proposed over the same admin set land in the same room, and the second is refused rather than silently returning the first's key. +## Phase 10 — the ceremony moves into the parent's room + +The thing [Phase 4](#why-not-the-parents-marmot-room) deferred, done, because the +reason to wait was `docs/mls-skipped-keys.md` and that is being fixed. The child's +ChillDKG and its key state now run in the parent's Marmot room on group events; +the sibling NIP-17 room is gone, and with it the rule that one admin set could +hold one subgroup forever. + +Schema 18 → 19, two nullable columns, both on `DkgSession`, and both are things +the sibling room used to answer: + +| column | why the room cannot answer it any more | +|---|---| +| `participantPublicKeys` | the ceremony is with a *subset* of the parent's members, so the room's roster names every person the subgroup is not | +| `subject` | the room this ceremony's key derives is not called what the parent is called, and the parent's name is the one name a child must not take | + +Both are read off the proposal, which has carried both since Phase 4 — the p-tags +and the `subject` tag. What changed is where they land: on the session, which is +the only thing in a shared room that knows which ceremony it is. + +### Three costs, and two of them are silent + +Phase 4 enumerated them. All three had to be paid, and the two that fail silently +are worth stating as rules rather than as changes. + +**1. `ChillDkgRitualManager` grows the two-transport shape.** `broadcast` writes a +`MarmotInnerEvent` where the room has MLS state and a `GiftWrapPayload` where it +does not, `replayStoredMessages` reads the backlog out of whichever store that +same reading names, `processRitualPayload` takes an `Event` rather than a +gift-wrap payload, and `NostrDao`'s Marmot arm dispatches the DKG kinds beside +the FROST ones it already did. + +**The p-tags stay on both transports**, unlike `FrostSigningManager`'s. There a +p-tag is an address and a group event needs none; here the p-tag set *is* the +participant set every device derives `n` from — and in the parent's room it is +also the only thing separating the subgroup's admins from everyone else who can +read the message. Dropping them the way the signing manager correctly does would +enrol the whole parent in the child's permanent signing quorum. Inside MLS +encryption, naming them leaks nothing. + +**2. `DkgSession.chatRoomId` stopped identifying a ceremony**, and the failure is +silent in the one place it matters. `FrostSigningManager.completedKey`'s last +fallback is "a ceremony held in this very room" — the fallback every parent member +welcomed after the group's own ceremony lands on, since they hold neither a share +nor a key state. The parent's room now holds a *completed* ceremony whose key is +the child's, and handing that back would have the parent sign as its own subgroup +with nothing to see. `getLatestOwnSessionForChatRoom` is that fallback scoped to +`parentChatRoomId IS NULL`: a ceremony run to make a subgroup is never the room's +own key, and that is the whole of what has to be read to know it. + +`proposeRitual`'s guard moved for the same reason and in the other direction — +see the Phase 8 row above. + +**3. `signingPath` admits a Marmot room**, narrowly. It has one case that cannot +be self-checked: the room a ceremony ran in, signing the statement that lets the +room the ceremony's key derives be created. That used to mean "a NIP-17 room whose +ceremony is its own"; it now also means "the parent's room, where the ceremony +claims that parent". The claim is unverified and grants nothing — the signature it +enables is by the *child's* key over the *child's* own id, both derived from a +ceremony every signer contributed to, so a member who lied about the parent gets +what telling the truth would have got them. + +### What it buys + +- **A subgroup no longer costs a room.** Nothing appears in anybody's chat list + that is not a group. +- **One admin set can run many subgroups.** The permanent collision is gone, + because there is no member-derived id left to collide; what is refused is only a + ceremony for the same people under the same parent that is still running. +- **Every step of a subgroup happens in one place.** The picker hands back to the + parent's transcript, and the ceremony, the certificate and the key state are all + answered from there — which is where `observeCeremoniesAwaitingYou` and + `observeProposalsAwaitingYou` were already looking. +- **`DkgSession.parentChatRoomId` is no longer a hint alone.** It still + authenticates nothing, and nothing may be granted on it that would not be + granted without it — but two lookups now scope on it, so the note where it is + declared has to be read before either is widened. + +### What it costs + +A ceremony now rides the transport where a message arriving a moment late is +dropped for good, which is the whole of Phase 4's objection and is not answered by +this phase — it is answered by the quartz fix `docs/mls-skipped-keys.md` +describes. A DKG is the worst payload for that bug: it cannot finish until every +participant takes part, so one lost round-1 message stalls it for everybody. +`replayStoredMessages` covers a message that lost a race to the proposal, since +the inner event is stored before it is dispatched; it cannot cover a message the +tree could never decrypt. + ## Rollout Nothing here changes an existing flow's behaviour. The two shared pieces are the @@ -911,20 +1043,13 @@ about whether the child's chronicle may carry an event its own key did not sign, which no chroniclable kind does today. That last one is the real work, and it is why this is a follow-up rather than a line in Phase 5. -**One parent, one admin set, one subgroup.** Narrower than it first shipped: a -room may hold two ceremonies, told apart by `DkgSession.parentChatRoomId`, so a -group's own ceremony and a subgroup over the same people coexist. What is still -refused is the same parent asking twice over the same admins, which have nothing -left to distinguish them. - -Widening that further needs the NIP-17 ceremony room to be distinguishable by -something other than its members, and `ChatRoom.deriveChatRoomId` is a pure -aggregate of member keys that the inbound path recomputes. Changing it is a change -to how every NIP-17 room in the app is addressed. - -It is also the limitation that disappears on its own: move the ceremony into the -parent's Marmot room, as [Phase 4](#why-not-the-parents-marmot-room) defers doing, -and there is no member-derived id left to collide. +**~~One parent, one admin set, one subgroup.~~ Gone in Phase 10.** It narrowed +twice and then disappeared. First to "the same parent asking twice over the same +admins", once `DkgSession.parentChatRoomId` told two ceremonies in a room apart. +Then to nothing permanent at all, because moving the ceremony into the parent's +Marmot room left no member-derived id to collide: what is refused now is only a +ceremony for those admins under that parent that is **still running**, and a +finished one is a subgroup that exists rather than a slot that is used up. **Every selected admin has to show up, twice.** A ChillDKG cannot finish without all `n`, and the key state then needs a quorum of them. A member who never @@ -964,12 +1089,14 @@ every parent member, and verifiable without the table. A table would be a second copy that can disagree with them, and the only thing it would add is flow state for the coordinator — which `DkgSession.parentChatRoomId` covers with one column. -**Running the child's ChillDKG and key state inside the parent's Marmot room.** -The better design, deferred rather than rejected, and argued in full in -[Phase 4](#why-not-the-parents-marmot-room). Short version: the two move -together, the mechanical cost is three enumerable changes, and the reason to wait -is `docs/mls-skipped-keys.md` — a ceremony needs every participant, and group -events lose one of any two that arrive back to back. +**~~Running the child's ChillDKG and key state inside the parent's Marmot room.~~** +Not rejected — deferred, argued in full in +[Phase 4](#why-not-the-parents-marmot-room), and then +[built](#phase-10--the-ceremony-moves-into-the-parents-room) once the reason to +wait was being fixed. The two did move together, the mechanical cost was the +three changes Phase 4 enumerated, and `docs/mls-skipped-keys.md` remains the thing +this leans on: a ceremony needs every participant, and group events lose one of +any two that arrive back to back until that lands. **A dedicated subgroup derivation path.** `m/9420/0/0` is reused, because the path is walked from the *child's own* key and two different keys at the same path