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