From d46b3d7ecb1924aafeeda2c3418e6f0668219cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:54:00 +0000 Subject: [PATCH 1/9] KV index --- backend/database.js | 104 +++++++++++++++++++++++++++++++++++++-- backend/routes.js | 17 ++----- backend/server.js | 25 ++-------- backend/zkppSalt.test.js | 2 +- 4 files changed, 110 insertions(+), 38 deletions(-) diff --git a/backend/database.js b/backend/database.js index f4766e396..a6f7f36a1 100644 --- a/backend/database.js +++ b/backend/database.js @@ -40,7 +40,7 @@ if (!fs.existsSync(dataFolder)) { } // Streams stored contract log entries since the given entry hash (inclusive!). -sbp('sbp/selectors/register', { +export default ((sbp('sbp/selectors/register', { 'backend/db/streamEntriesAfter': async function (contractID: string, height: string, requestedLimit: ?number): Promise<*> { const limit = Math.min(requestedLimit ?? Number.POSITIVE_INFINITY, process.env.MAX_EVENTS_BATCH_SIZE ?? 500) const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID) @@ -113,13 +113,13 @@ sbp('sbp/selectors/register', { const value = await sbp('chelonia/db/get', namespaceKey(name)) return value || Boom.notFound() } -}) +}): any): string[]) function namespaceKey (name: string): string { return 'name=' + name } -export default async () => { +export const initDB = async () => { // If persistence must be enabled: // - load and initialize the selected storage backend // - then overwrite 'chelonia/db/get' and '-set' to use it with an LRU cache @@ -214,3 +214,101 @@ export default async () => { } await Promise.all([initVapid(), initZkpp()]) } + +// Index management + +/** + * Creates a factory function that appends a value to a string index in a + * database. + * The index is identified by the provided key. The value is appended only if it + * does not already exist in the index. + * + * @param key - The key that identifies the index in the database. + * @returns A function that takes a value to append to the index. + */ +export const appendToIndexFactory = (key: string): (value: string) => Promise => { + return (value: string) => { + // We want to ensure that the index is updated atomically (i.e., if there + // are multiple additions, all of them should be handled), so a queue + // is needed for the load & store operation. + return sbp('okTurtles.eventQueue/queueEvent', key, async () => { + // Retrieve the current index from the database using the provided key + const currentIndex = await sbp('chelonia/db/get', key) + + // If the current index exists, check if the value is already present + if (currentIndex) { + // Check for existing value to avoid duplicates + if ( + // Check if the value is at the end + currentIndex.endsWith('\x00' + value) || + // Check if the value is at the start + currentIndex.startsWith(value + '\x00') || + // Check if the current index is exactly the value + currentIndex === value + ) { + // Exit if the value already exists + return + } + + // Append the new value to the current index, separated by NUL + await sbp('chelonia/db/set', key, `${currentIndex}\x00${value}`) + return + } + + // If the current index does not exist, set it to the new value + await sbp('chelonia/db/set', key, value) + }) + } +} + +/** + * Creates a factory function that removes a value from a string index in a + * database. + * The index is identified by the provided key. The function handles various + * cases to ensure the value is correctly removed from the index. + * + * @param key - The key that identifies the index in the database. + * @returns A function that takes a value to remove from the index. + */ +export const removeFromIndexFactory = (key: string): (value: string) => Promise => { + return (value: string) => { + return sbp('okTurtles.eventQueue/queueEvent', key, async () => { + // Retrieve the existing entries from the database using the provided key + const existingEntries = await sbp('chelonia/db/get', key) + // Exit if there are no existing entries + if (!existingEntries) return + + // Handle the case where the value is at the end of the entries + if (existingEntries.endsWith('\x00' + value)) { + await sbp('chelonia/db/set', key, existingEntries.slice(0, -value.length - 1)) + return + } + + // Handle the case where the value is at the start of the entries + if (existingEntries.startsWith(value + '\x00')) { + await sbp('chelonia/db/set', key, existingEntries.slice(value.length + 1)) + return + } + + // Handle the case where the existing entries are exactly the value + if (existingEntries === value) { + await sbp('chelonia/db/delete', key) + return + } + + // Find the index of the value in the existing entries + const entryIndex = existingEntries.indexOf('\x00' + value + '\x00') + if (entryIndex === -1) return + + // Create an updated index by removing the value + const updatedIndex = existingEntries.slice(0, entryIndex) + existingEntries.slice(entryIndex + value.length + 1) + + // Update the index in the database or delete it if empty + if (updatedIndex) { + await sbp('chelonia/db/set', key, updatedIndex) + } else { + await sbp('chelonia/db/delete', key) + } + }) + } +} diff --git a/backend/routes.js b/backend/routes.js index b61a06c8a..c6e3cdc84 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -8,7 +8,7 @@ import { createCID } from '~/shared/functions.js' import { SERVER_INSTANCE } from './instance-keys.js' import path from 'path' import chalk from 'chalk' -import './database.js' +import { appendToIndexFactory, removeFromIndexFactory } from './database.js' import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt, redeemSaltUpdateToken } from './zkppSalt.js' import Bottleneck from 'bottleneck' @@ -555,19 +555,7 @@ route.POST('/deleteFile/{hash}', { await sbp('chelonia/db/delete', `_private_size_${hash}`) await sbp('chelonia/db/delete', `_private_deletionToken_${hash}`) const resourcesKey = `_private_resources_${owner}` - // Use a queue for atomicity - await sbp('okTurtles.eventQueue/queueEvent', resourcesKey, async () => { - const existingResources = await sbp('chelonia/db/get', resourcesKey) - if (!existingResources) return - if (existingResources.endsWith(hash)) { - await sbp('chelonia/db/set', resourcesKey, existingResources.slice(0, -hash.length - 1)) - return - } - const hashIndex = existingResources.indexOf(hash + '\x00') - if (hashIndex === -1) return - await sbp('chelonia/db/set', resourcesKey, existingResources.slice(0, hashIndex) + existingResources.slice(hashIndex + hash.length + 1)) - }) - + await removeFromIndexFactory(resourcesKey)(hash) return h.response() }) @@ -651,6 +639,7 @@ route.POST('/kv/{contractID}/{key}', { const existingSize = existing ? Buffer.from(existing).byteLength : 0 await sbp('chelonia/db/set', `_private_kv_${contractID}_${key}`, request.payload) await sbp('backend/server/updateSize', contractID, request.payload.byteLength - existingSize) + await appendToIndexFactory(`_private_kvIdx_${contractID}`)(key) await sbp('backend/server/broadcastKV', contractID, key, request.payload.toString()) return h.response().code(204) diff --git a/backend/server.js b/backend/server.js index 2fa115608..d1b6ae2b7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,7 @@ import sbp from '@sbp/sbp' import chalk from 'chalk' import '~/shared/domains/chelonia/chelonia.js' import { SERVER } from '~/shared/domains/chelonia/presets.js' -import initDB from './database.js' +import { appendToIndexFactory, initDB } from './database.js' import { SERVER_RUNNING } from './events.js' import { PUBSUB_INSTANCE, SERVER_INSTANCE } from './instance-keys.js' import { @@ -132,16 +132,10 @@ sbp('sbp/selectors/register', { // We want to ensure that the index is updated atomically (i.e., if there // are multiple new contracts, all of them should be added), so a queue // is needed for the load & store operation. - await sbp('okTurtles.eventQueue/queueEvent', 'update-contract-indices', async () => { - const currentIndex = await sbp('chelonia/db/get', '_private_cheloniaState_index') - // Add the current contract ID to the contract index. Entries in the - // index are separated by \x00 (NUL). The index itself is used to know - // which entries to load. - const updatedIndex = `${currentIndex ? `${currentIndex}\x00` : ''}${contractID}` - await sbp('chelonia/db/set', '_private_cheloniaState_index', updatedIndex) - }) + await sbp('backend/server/appendToContractIndex', contractID) } }, + 'backend/server/appendToContractIndex': appendToIndexFactory('_private_cheloniaState_index'), 'backend/server/broadcastKV': async function (contractID: string, key: string, entry: string) { const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) const pubsubMessage = createKvMessage(contractID, key, entry) @@ -175,18 +169,9 @@ sbp('sbp/selectors/register', { // Store the resource in the resource index key // This is done in a queue to handle several simultaneous requests // reading and writing to the same key - await sbp('okTurtles.eventQueue/queueEvent', resourcesKey, async () => { - const existingResources = await sbp('chelonia/db/get', resourcesKey) - await sbp('chelonia/db/set', resourcesKey, (existingResources ? existingResources + '\x00' : '') + resourceID) - }) - }, - 'backend/server/registerBillableEntity': async function (resourceID: string) { - // Use a queue to ensure atomic updates - await sbp('okTurtles.eventQueue/queueEvent', '_private_billable_entities', async () => { - const existingBillableEntities = await sbp('chelonia/db/get', '_private_billable_entities') - await sbp('chelonia/db/set', '_private_billable_entities', (existingBillableEntities ? existingBillableEntities + '\x00' : '') + resourceID) - }) + await appendToIndexFactory(resourcesKey)(resourceID) }, + 'backend/server/registerBillableEntity': appendToIndexFactory('_private_billable_entities'), 'backend/server/updateSize': async function (resourceID: string, size: number) { const sizeKey = `_private_size_${resourceID}` if (!Number.isSafeInteger(size)) { diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index 840820dc8..bd8096ceb 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -2,7 +2,7 @@ import nacl from 'tweetnacl' import should from 'should' -import initDB from './database.js' +import { initDB } from './database.js' import 'should-sinon' import { AUTHSALT, CONTRACTSALT, CS, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.js' From f143e08983ee4414d55c859fba9e092f4f48299a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 8 Jan 2025 12:01:08 +0000 Subject: [PATCH 2/9] Move delete file into a dedicated selector --- backend/errors.js | 6 ++++++ backend/routes.js | 29 ++++++++++------------------- backend/server.js | 27 ++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 backend/errors.js diff --git a/backend/errors.js b/backend/errors.js new file mode 100644 index 000000000..3a6bddda7 --- /dev/null +++ b/backend/errors.js @@ -0,0 +1,6 @@ +'use strict' + +import { ChelErrorGenerator } from '~/shared/domains/chelonia/errors.js' + +export const BackendErrorNotFound: typeof Error = ChelErrorGenerator('BackendErrorNotFound') +export const BackendErrorBadData: typeof Error = ChelErrorGenerator('BackendErrorBadData') diff --git a/backend/routes.js b/backend/routes.js index c6e3cdc84..e2c4ebf0a 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -535,28 +535,19 @@ route.POST('/deleteFile/{hash}', { // Authentication passed, now proceed to delete the file and its associated // keys - const rawManifest = await sbp('chelonia/db/get', hash) - if (!rawManifest) return Boom.notFound() try { - const manifest = JSON.parse(rawManifest) - if (!manifest || typeof manifest !== 'object') return Boom.badData('manifest format is invalid') - if (manifest.version !== '1.0.0') return Boom.badData('unsupported manifest version') - if (!Array.isArray(manifest.chunks) || !manifest.chunks.length) return Boom.badData('missing chunks') - // Delete all chunks - await Promise.all(manifest.chunks.map(([, cid]) => sbp('chelonia/db/delete', cid))) + await sbp('backend/deleteFile', hash) + return h.response() } catch (e) { - console.warn(e, `Error parsing manifest for ${hash}. It's probably not a file manifest.`) - return Boom.notFound() + switch (e.name) { + case 'BackendErrorNotFound': + return Boom.notFound() + case 'BackendErrorBadData': + return Boom.badData(e.message) + default: + return Boom.internal(e.message) + } } - // The keys to be deleted are not read from or updated, so they can be deleted - // without using a queue - await sbp('chelonia/db/delete', hash) - await sbp('chelonia/db/delete', `_private_owner_${hash}`) - await sbp('chelonia/db/delete', `_private_size_${hash}`) - await sbp('chelonia/db/delete', `_private_deletionToken_${hash}`) - const resourcesKey = `_private_resources_${owner}` - await removeFromIndexFactory(resourcesKey)(hash) - return h.response() }) route.POST('/kv/{contractID}/{key}', { diff --git a/backend/server.js b/backend/server.js index d1b6ae2b7..dc111b53f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,7 @@ import sbp from '@sbp/sbp' import chalk from 'chalk' import '~/shared/domains/chelonia/chelonia.js' import { SERVER } from '~/shared/domains/chelonia/presets.js' -import { appendToIndexFactory, initDB } from './database.js' +import { appendToIndexFactory, initDB, removeFromIndexFactory } from './database.js' import { SERVER_RUNNING } from './events.js' import { PUBSUB_INSTANCE, SERVER_INSTANCE } from './instance-keys.js' import { @@ -20,6 +20,7 @@ import { import { addChannelToSubscription, deleteChannelFromSubscription, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' +import { BackendErrorBadData, BackendErrorNotFound } from './errors.js' // Node.js version 18 and lower don't have global.crypto defined // by default @@ -198,6 +199,30 @@ sbp('sbp/selectors/register', { }, 'backend/server/stop': function () { return hapi.stop() + }, + async 'backend/deleteFile' (cid: string): Promise { + const owner = await sbp('chelonia/db/get', `_private_owner_${cid}`) + const rawManifest = await sbp('chelonia/db/get', cid) + if (!rawManifest) throw new BackendErrorNotFound() + try { + const manifest = JSON.parse(rawManifest) + if (!manifest || typeof manifest !== 'object') throw new BackendErrorBadData('manifest format is invalid') + if (manifest.version !== '1.0.0') return BackendErrorBadData('unsupported manifest version') + if (!Array.isArray(manifest.chunks) || !manifest.chunks.length) return BackendErrorBadData('missing chunks') + // Delete all chunks + await Promise.all(manifest.chunks.map(([, cid]) => sbp('chelonia/db/delete', cid))) + } catch (e) { + console.warn(e, `Error parsing manifest for ${cid}. It's probably not a file manifest.`) + throw new BackendErrorNotFound() + } + // The keys to be deleted are not read from or updated, so they can be deleted + // without using a queue + await sbp('chelonia/db/delete', cid) + await sbp('chelonia/db/delete', `_private_owner_${cid}`) + await sbp('chelonia/db/delete', `_private_size_${cid}`) + await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) + const resourcesKey = `_private_resources_${owner}` + await removeFromIndexFactory(resourcesKey)(cid) } }) From e08d52f8219b9e6ac2d45ad6c399bec73fb4837b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:10:45 +0000 Subject: [PATCH 3/9] Resource deletion basic implementation --- backend/database.js | 4 ++ backend/errors.js | 1 + backend/routes.js | 86 ++++++++++++++++++++++++++++- backend/server.js | 78 +++++++++++++++++++++++--- shared/domains/chelonia/chelonia.js | 31 +++++++++++ shared/domains/chelonia/db.js | 3 + 6 files changed, 192 insertions(+), 11 deletions(-) diff --git a/backend/database.js b/backend/database.js index a6f7f36a1..26fcbd59a 100644 --- a/backend/database.js +++ b/backend/database.js @@ -42,6 +42,10 @@ if (!fs.existsSync(dataFolder)) { // Streams stored contract log entries since the given entry hash (inclusive!). export default ((sbp('sbp/selectors/register', { 'backend/db/streamEntriesAfter': async function (contractID: string, height: string, requestedLimit: ?number): Promise<*> { + const resource = await sbp('chelonia/db/get', contractID) + if (resource === '') { + throw Boom.resourceGone(`contractID ${contractID} has been deleted!`) + } const limit = Math.min(requestedLimit ?? Number.POSITIVE_INFINITY, process.env.MAX_EVENTS_BATCH_SIZE ?? 500) const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID) if (!latestHEADinfo) { diff --git a/backend/errors.js b/backend/errors.js index 3a6bddda7..306190a15 100644 --- a/backend/errors.js +++ b/backend/errors.js @@ -3,4 +3,5 @@ import { ChelErrorGenerator } from '~/shared/domains/chelonia/errors.js' export const BackendErrorNotFound: typeof Error = ChelErrorGenerator('BackendErrorNotFound') +export const BackendErrorGone: typeof Error = ChelErrorGenerator('BackendErrorGone') export const BackendErrorBadData: typeof Error = ChelErrorGenerator('BackendErrorBadData') diff --git a/backend/routes.js b/backend/routes.js index e2c4ebf0a..ee9f4d860 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -8,7 +8,7 @@ import { createCID } from '~/shared/functions.js' import { SERVER_INSTANCE } from './instance-keys.js' import path from 'path' import chalk from 'chalk' -import { appendToIndexFactory, removeFromIndexFactory } from './database.js' +import { appendToIndexFactory } from './database.js' import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt, redeemSaltUpdateToken } from './zkppSalt.js' import Bottleneck from 'bottleneck' @@ -472,7 +472,9 @@ route.GET('/file/{hash}', { } const blobOrString = await sbp('chelonia/db/get', `any:${hash}`) - if (!blobOrString) { + if (blobOrString === '') { + return Boom.resourceGone() + } else if (!blobOrString) { return Boom.notFound() } return h.response(blobOrString).etag(hash) @@ -542,10 +544,88 @@ route.POST('/deleteFile/{hash}', { switch (e.name) { case 'BackendErrorNotFound': return Boom.notFound() + case 'BackendErrorGone': + return Boom.resourceGone() case 'BackendErrorBadData': return Boom.badData(e.message) default: - return Boom.internal(e.message) + console.error(e, 'Error during deletion') + return Boom.internal(e.message ?? 'internal error') + } + } +}) + +route.POST('/deleteContract/{hash}', { + auth: { + // Allow file deletion, and allow either the bearer of the deletion token or + // the file owner to delete it + strategies: ['chel-shelter', 'chel-bearer'], + mode: 'required' + } +}, async function (request, h) { + const { hash } = request.params + const strategy = request.auth.strategy + if (!hash || hash.startsWith('_private')) return Boom.notFound() + const owner = await sbp('chelonia/db/get', `_private_owner_${hash}`) + if (!owner) { + return Boom.notFound() + } + + switch (strategy) { + case 'chel-shelter': { + let ultimateOwner = owner + let count = 0 + // Walk up the ownership tree + do { + const owner = await sbp('chelonia/db/get', `_private_owner_${ultimateOwner}`) + if (owner) { + ultimateOwner = owner + count++ + } else { + break + } + // Prevent an infinite loop + } while (count < 128) + // Check that the user making the request is the ultimate owner (i.e., + // that they have permission to delete this file) + if (!ctEq(request.auth.credentials.billableContractID, ultimateOwner)) { + return Boom.unauthorized('Invalid token', 'bearer') + } + break + } + case 'chel-bearer': { + const expectedToken = await sbp('chelonia/db/get', `_private_deletionToken_${hash}`) + if (!expectedToken) { + return Boom.notFound() + } + const token = request.auth.credentials.token + // Constant-time comparison + // Check that the token provided matches the deletion token for this contract + if (!ctEq(expectedToken, token)) { + return Boom.unauthorized('Invalid token', 'bearer') + } + break + } + default: + return Boom.unauthorized('Missing or invalid auth strategy') + } + + // Authentication passed, now proceed to delete the contract and its associated + // keys + try { + await sbp('backend/deleteContract', hash) + return h.response() + } catch (e) { + switch (e.name) { + case 'BackendErrorNotFound': + return Boom.notFound() + case 'BackendErrorGone': + return Boom.resourceGone() + case 'BackendErrorBadData': + return Boom.badData(e.message) + default: + console.error(e, 'Error during deletion') + return Boom.internal(e.message ?? 'internal error') } } }) diff --git a/backend/server.js b/backend/server.js index dc111b53f..0ad32b059 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3,9 +3,12 @@ import Hapi from '@hapi/hapi' import sbp from '@sbp/sbp' import chalk from 'chalk' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import '~/shared/domains/chelonia/chelonia.js' import { SERVER } from '~/shared/domains/chelonia/presets.js' +import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' import { appendToIndexFactory, initDB, removeFromIndexFactory } from './database.js' +import { BackendErrorBadData, BackendErrorGone, BackendErrorNotFound } from './errors.js' import { SERVER_RUNNING } from './events.js' import { PUBSUB_INSTANCE, SERVER_INSTANCE } from './instance-keys.js' import { @@ -18,9 +21,6 @@ import { createServer } from './pubsub.js' import { addChannelToSubscription, deleteChannelFromSubscription, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js' -import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' -import { BackendErrorBadData, BackendErrorNotFound } from './errors.js' // Node.js version 18 and lower don't have global.crypto defined // by default @@ -203,12 +203,14 @@ sbp('sbp/selectors/register', { async 'backend/deleteFile' (cid: string): Promise { const owner = await sbp('chelonia/db/get', `_private_owner_${cid}`) const rawManifest = await sbp('chelonia/db/get', cid) + if (rawManifest === '') throw new BackendErrorGone() if (!rawManifest) throw new BackendErrorNotFound() + try { const manifest = JSON.parse(rawManifest) if (!manifest || typeof manifest !== 'object') throw new BackendErrorBadData('manifest format is invalid') - if (manifest.version !== '1.0.0') return BackendErrorBadData('unsupported manifest version') - if (!Array.isArray(manifest.chunks) || !manifest.chunks.length) return BackendErrorBadData('missing chunks') + if (manifest.version !== '1.0.0') throw BackendErrorBadData('unsupported manifest version') + if (!Array.isArray(manifest.chunks) || !manifest.chunks.length) throw BackendErrorBadData('missing chunks') // Delete all chunks await Promise.all(manifest.chunks.map(([, cid]) => sbp('chelonia/db/delete', cid))) } catch (e) { @@ -217,12 +219,72 @@ sbp('sbp/selectors/register', { } // The keys to be deleted are not read from or updated, so they can be deleted // without using a queue - await sbp('chelonia/db/delete', cid) + const resourcesKey = `_private_resources_${owner}` + await removeFromIndexFactory(resourcesKey)(cid) + await sbp('chelonia/db/delete', `_private_owner_${cid}`) await sbp('chelonia/db/delete', `_private_size_${cid}`) await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) - const resourcesKey = `_private_resources_${owner}` - await removeFromIndexFactory(resourcesKey)(cid) + + await sbp('chelonia/db/set', cid, '') + }, + async 'backend/deleteContract' (cid: string): Promise { + const owner = await sbp('chelonia/db/get', `_private_owner_${cid}`) + const rawManifest = await sbp('chelonia/db/get', cid) + if (rawManifest === '') throw new BackendErrorGone() + if (!rawManifest) throw new BackendErrorNotFound() + + const resourcesKey = `_private_resources_${cid}` + const resources = await sbp('chelonia/db/get', resourcesKey) + if (resources) { + await Promise.allSettled(resources.split('\x00').map(async (resourceCid) => { + // TODO: Temporary logic until we can figure out the resource type + // directly from a CID + const resource = Buffer.from(await sbp('chelonia/db/get', resourceCid)).toString() + if (resource) { + if (resource.includes('previousHEAD') && resource.includes('contractID') && resource.includes('op') && resource.includes('height')) { + return sbp('backend/deleteContract', resourceCid) + } else { + return sbp('backend/deleteFile', resourceCid) + } + } + })) + } + await sbp('chelonia/db/delete', resourcesKey) + + const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', cid) + if (latestHEADinfo) { + for (let i = latestHEADinfo.height; i > 0; i--) { + const eventKey = `_private_hidx=${cid}#${i}` + const event = await sbp('chelonia/db/get', eventKey) + if (event) { + await sbp('chelonia/db/delete', event) + await sbp('chelonia/db/delete', eventKey) + } + } + await sbp('chelonia/db/deleteLatestHEADinfo', cid) + } + + const kvIndexKey = `_private_kvIdx_${cid}` + const kvKeys = await sbp('chelonia/db/get', kvIndexKey) + if (kvKeys) { + await kvKeys.split('\x00').map((key) => { + return sbp('chelonia/db/delete', key) + }) + } + + await sbp('chelonia/db/delete', `_private_rid_${cid}`) + await sbp('chelonia/db/delete', `_private_owner_${cid}`) + await sbp('chelonia/db/delete', `_private_size_${cid}`) + await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) + await removeFromIndexFactory(kvIndexKey)(cid) + await removeFromIndexFactory(`_private_resources_${owner}`)(cid) + + await sbp('chelonia/db/set', cid, '') + + await sbp('chelonia/db/delete', `_private_cheloniaState_${cid}`) + await removeFromIndexFactory('_private_cheloniaState_index')(cid) + await removeFromIndexFactory('_private_billable_entities')(cid) } }) diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index aa96b58ab..6aea379c4 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -1161,6 +1161,37 @@ export default (sbp('sbp/selectors/register', { }) return msg }, + 'chelonia/out/deleteContract': async function (contractID: string | string[], credentials: { [contractID: string]: { token: ?string, billableContractID: ?string } } = {}) { + if (!contractID) { + throw new TypeError('A manifest CID must be provided') + } + if (!Array.isArray(contractID)) contractID = [contractID] + return await Promise.allSettled(contractID.map(async (cid) => { + const hasCredential = has(credentials, cid) + const hasToken = has(credentials[cid], 'token') && credentials[cid].token + const hasBillableContractID = has(credentials[cid], 'billableContractID') && credentials[cid].billableContractID + if (!hasCredential || (!hasToken && hasToken === hasBillableContractID)) { + throw new TypeError(`Either a token or a billable contract ID must be provided for ${cid}`) + } + + const response = await fetch(`${this.config.connectionURL}/deleteContract/${cid}`, { + method: 'POST', + signal: this.abortController.signal, + headers: new Headers([ + ['authorization', + hasToken + // $FlowFixMe[incompatible-type] + ? `bearer ${credentials[cid].token}` + // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-call] + : buildShelterAuthorizationHeader.call(this, credentials[cid].billableContractID)] + ]) + }) + if (!response.ok) { + throw new Error(`Unable to delete contract ${cid}`) + } + })) + }, // all of these functions will do both the creation of the GIMessage // and the sending of it via 'chelonia/private/out/publishEvent' 'chelonia/out/actionEncrypted': function (params: ChelActionParams): Promise { diff --git a/shared/domains/chelonia/db.js b/shared/domains/chelonia/db.js index df4e44564..3def3f356 100644 --- a/shared/domains/chelonia/db.js +++ b/shared/domains/chelonia/db.js @@ -101,6 +101,9 @@ export default (sbp('sbp/selectors/register', { 'chelonia/db/latestHEADinfo': (contractID: string): Promise => { return sbp('chelonia/db/get', getLogHead(contractID)).then((r) => r && JSON.parse(r)) }, + 'chelonia/db/deleteLatestHEADinfo': (contractID: string): Promise => { + return sbp('chelonia/db/delete', getLogHead(contractID)) + }, 'chelonia/db/getEntry': async function (hash: string): Promise { try { const value: string = await sbp('chelonia/db/get', hash) From 5d0bd106f05bdaaf16d27054e941382b5ac36692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 8 Jan 2025 19:18:42 +0000 Subject: [PATCH 4/9] Use queues --- backend/server.js | 126 +++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/backend/server.js b/backend/server.js index 0ad32b059..ba873f833 100644 --- a/backend/server.js +++ b/backend/server.js @@ -165,12 +165,20 @@ sbp('sbp/selectors/register', { }, 'backend/server/saveOwner': async function (ownerID: string, resourceID: string) { // Store the owner for the current resource - await sbp('chelonia/db/set', `_private_owner_${resourceID}`, ownerID) - const resourcesKey = `_private_resources_${ownerID}` - // Store the resource in the resource index key - // This is done in a queue to handle several simultaneous requests - // reading and writing to the same key - await appendToIndexFactory(resourcesKey)(resourceID) + // Use a queue to check that the owner exists, preventing the creation of + // orphaned resources (e.g., because the owner was just deleted) + await sbp('chelonia/queueInvocation', ownerID, async () => { + const owner = await sbp('chelonia/db/get', ownerID) + if (!owner) { + throw new Error('Owner resource does not exist') + } + await sbp('chelonia/db/set', `_private_owner_${resourceID}`, ownerID) + const resourcesKey = `_private_resources_${ownerID}` + // Store the resource in the resource index key + // This is done in a queue to handle several simultaneous requests + // reading and writing to the same key + await appendToIndexFactory(resourcesKey)(resourceID) + }) }, 'backend/server/registerBillableEntity': appendToIndexFactory('_private_billable_entities'), 'backend/server/updateSize': async function (resourceID: string, size: number) { @@ -228,63 +236,79 @@ sbp('sbp/selectors/register', { await sbp('chelonia/db/set', cid, '') }, + // eslint-disable-next-line require-await async 'backend/deleteContract' (cid: string): Promise { - const owner = await sbp('chelonia/db/get', `_private_owner_${cid}`) - const rawManifest = await sbp('chelonia/db/get', cid) - if (rawManifest === '') throw new BackendErrorGone() - if (!rawManifest) throw new BackendErrorNotFound() + let contractsPendingDeletion = sbp('okTurtles.data/get', 'contractsPendingDeletion') + if (!contractsPendingDeletion) { + contractsPendingDeletion = new Set() + sbp('okTurtles.data/set', 'contractsPendingDeletion', contractsPendingDeletion) + } + // Avoid deadlocks due to loops + if (contractsPendingDeletion.has(cid)) { + return + } + contractsPendingDeletion.add(cid) - const resourcesKey = `_private_resources_${cid}` - const resources = await sbp('chelonia/db/get', resourcesKey) - if (resources) { - await Promise.allSettled(resources.split('\x00').map(async (resourceCid) => { + return sbp('chelonia/queueInvocation', cid, async () => { + const owner = await sbp('chelonia/db/get', `_private_owner_${cid}`) + const rawManifest = await sbp('chelonia/db/get', cid) + if (rawManifest === '') throw new BackendErrorGone() + if (!rawManifest) throw new BackendErrorNotFound() + + const resourcesKey = `_private_resources_${cid}` + const resources = await sbp('chelonia/db/get', resourcesKey) + if (resources) { + await Promise.allSettled(resources.split('\x00').map(async (resourceCid) => { // TODO: Temporary logic until we can figure out the resource type // directly from a CID - const resource = Buffer.from(await sbp('chelonia/db/get', resourceCid)).toString() - if (resource) { - if (resource.includes('previousHEAD') && resource.includes('contractID') && resource.includes('op') && resource.includes('height')) { - return sbp('backend/deleteContract', resourceCid) - } else { - return sbp('backend/deleteFile', resourceCid) + const resource = Buffer.from(await sbp('chelonia/db/get', resourceCid)).toString() + if (resource) { + if (resource.includes('previousHEAD') && resource.includes('contractID') && resource.includes('op') && resource.includes('height')) { + return sbp('backend/deleteContract', resourceCid) + } else { + return sbp('backend/deleteFile', resourceCid) + } } - } - })) - } - await sbp('chelonia/db/delete', resourcesKey) + })) + } + await sbp('chelonia/db/delete', resourcesKey) - const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', cid) - if (latestHEADinfo) { - for (let i = latestHEADinfo.height; i > 0; i--) { - const eventKey = `_private_hidx=${cid}#${i}` - const event = await sbp('chelonia/db/get', eventKey) - if (event) { - await sbp('chelonia/db/delete', event) - await sbp('chelonia/db/delete', eventKey) + const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', cid) + if (latestHEADinfo) { + for (let i = latestHEADinfo.height; i > 0; i--) { + const eventKey = `_private_hidx=${cid}#${i}` + const event = await sbp('chelonia/db/get', eventKey) + if (event) { + await sbp('chelonia/db/delete', event) + await sbp('chelonia/db/delete', eventKey) + } } + await sbp('chelonia/db/deleteLatestHEADinfo', cid) } - await sbp('chelonia/db/deleteLatestHEADinfo', cid) - } - const kvIndexKey = `_private_kvIdx_${cid}` - const kvKeys = await sbp('chelonia/db/get', kvIndexKey) - if (kvKeys) { - await kvKeys.split('\x00').map((key) => { - return sbp('chelonia/db/delete', key) - }) - } + const kvIndexKey = `_private_kvIdx_${cid}` + const kvKeys = await sbp('chelonia/db/get', kvIndexKey) + if (kvKeys) { + await kvKeys.split('\x00').map((key) => { + return sbp('chelonia/db/delete', key) + }) + } - await sbp('chelonia/db/delete', `_private_rid_${cid}`) - await sbp('chelonia/db/delete', `_private_owner_${cid}`) - await sbp('chelonia/db/delete', `_private_size_${cid}`) - await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) - await removeFromIndexFactory(kvIndexKey)(cid) - await removeFromIndexFactory(`_private_resources_${owner}`)(cid) + await sbp('chelonia/db/delete', `_private_rid_${cid}`) + await sbp('chelonia/db/delete', `_private_owner_${cid}`) + await sbp('chelonia/db/delete', `_private_size_${cid}`) + await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) + await removeFromIndexFactory(kvIndexKey)(cid) + await removeFromIndexFactory(`_private_resources_${owner}`)(cid) - await sbp('chelonia/db/set', cid, '') + await sbp('chelonia/db/set', cid, '') - await sbp('chelonia/db/delete', `_private_cheloniaState_${cid}`) - await removeFromIndexFactory('_private_cheloniaState_index')(cid) - await removeFromIndexFactory('_private_billable_entities')(cid) + await sbp('chelonia/db/delete', `_private_cheloniaState_${cid}`) + await removeFromIndexFactory('_private_cheloniaState_index')(cid) + await removeFromIndexFactory('_private_billable_entities')(cid) + }).finally(() => { + contractsPendingDeletion.delete(cid) + }) } }) From 0b729020356cacf8827c04d13896eefe66441277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:05:55 +0000 Subject: [PATCH 5/9] Contract deletion handling --- backend/database.js | 7 +++---- backend/index.js | 6 +++++- backend/routes.js | 3 +++ backend/server.js | 10 ++++++++++ frontend/controller/utils/misc.js | 4 +++- frontend/setupChelonia.js | 10 ++++++++++ shared/domains/chelonia/chelonia.js | 2 ++ shared/domains/chelonia/db.js | 2 +- shared/domains/chelonia/errors.js | 1 + shared/pubsub.js | 1 + 10 files changed, 39 insertions(+), 7 deletions(-) diff --git a/backend/database.js b/backend/database.js index 26fcbd59a..8677cef9a 100644 --- a/backend/database.js +++ b/backend/database.js @@ -42,12 +42,11 @@ if (!fs.existsSync(dataFolder)) { // Streams stored contract log entries since the given entry hash (inclusive!). export default ((sbp('sbp/selectors/register', { 'backend/db/streamEntriesAfter': async function (contractID: string, height: string, requestedLimit: ?number): Promise<*> { - const resource = await sbp('chelonia/db/get', contractID) - if (resource === '') { - throw Boom.resourceGone(`contractID ${contractID} has been deleted!`) - } const limit = Math.min(requestedLimit ?? Number.POSITIVE_INFINITY, process.env.MAX_EVENTS_BATCH_SIZE ?? 500) const latestHEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID) + if (latestHEADinfo === '') { + throw Boom.resourceGone(`contractID ${contractID} has been deleted!`) + } if (!latestHEADinfo) { throw Boom.notFound(`contractID ${contractID} doesn't exist!`) } diff --git a/backend/index.js b/backend/index.js index e5569f4c5..f2fcdb50b 100644 --- a/backend/index.js +++ b/backend/index.js @@ -50,7 +50,11 @@ console.error = logger.error.bind(logger) console.info('NODE_ENV =', process.env.NODE_ENV) -const dontLog = { 'backend/server/broadcastEntry': true, 'backend/server/broadcastKV': true } +const dontLog = { + 'backend/server/broadcastEntry': true, + 'backend/server/broadcastDeletion': true, + 'backend/server/broadcastKV': true +} function logSBP (domain, selector, data: Array<*>) { if (!dontLog[selector]) { diff --git a/backend/routes.js b/backend/routes.js index ee9f4d860..6d1f035c1 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -275,6 +275,9 @@ route.GET('/latestHEADinfo/{contractID}', { try { if (contractID.startsWith('_private')) return Boom.notFound() const HEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID) + if (HEADinfo === '') { + return Boom.resourceGone() + } if (!HEADinfo) { console.warn(`[backend] latestHEADinfo not found for ${contractID}`) return Boom.notFound() diff --git a/backend/server.js b/backend/server.js index ba873f833..99a50617b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -153,6 +153,13 @@ sbp('sbp/selectors/register', { console.debug(chalk.blue.bold(`[pubsub] Broadcasting ${deserializedHEAD.description()}`)) await pubsub.broadcast(pubsubMessage, { to: subscribers }) }, + 'backend/server/broadcastDeletion': async function (contractID: string) { + const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE) + const pubsubMessage = createMessage(NOTIFICATION_TYPE.DELETION, contractID) + const subscribers = pubsub.enumerateSubscribers(contractID) + console.debug(chalk.blue.bold(`[pubsub] Broadcasting deletion of ${contractID}`)) + await pubsub.broadcast(pubsubMessage, { to: subscribers }) + }, 'backend/server/handleEntry': async function (deserializedHEAD: Object, entry: string) { const contractID = deserializedHEAD.contractID if (deserializedHEAD.head.op === GIMessage.OP_CONTRACT) { @@ -306,6 +313,9 @@ sbp('sbp/selectors/register', { await sbp('chelonia/db/delete', `_private_cheloniaState_${cid}`) await removeFromIndexFactory('_private_cheloniaState_index')(cid) await removeFromIndexFactory('_private_billable_entities')(cid) + sbp('backend/server/broadcastDeletion', cid).catch(e => { + console.error(e, 'Error broadcasting contract deletion', cid) + }) }).finally(() => { contractsPendingDeletion.delete(cid) }) diff --git a/frontend/controller/utils/misc.js b/frontend/controller/utils/misc.js index b59c1fbfc..9c34ca855 100644 --- a/frontend/controller/utils/misc.js +++ b/frontend/controller/utils/misc.js @@ -1,8 +1,10 @@ 'use strict' +import { ChelErrorUnexpectedHttpResponseCode } from '~/shared/domains/chelonia/errors.js' + export function handleFetchResult (type: string): ((r: any) => any) { return function (r: Object) { - if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) + if (!r.ok) throw new ChelErrorUnexpectedHttpResponseCode(`${r.status}: ${r.statusText}`) return r[type]() } } diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index a3f5217fa..bb2d4438d 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -139,6 +139,12 @@ const setupChelonia = async (): Promise<*> => { } }, hooks: { + syncContractError: (e, contractID) => { + // TODO + if (e?.name === 'ChelErrorUnexpectedHttpResponseCode' && e.message.startsWith('410:')) { + console.error('@@@@[syncContractError] Contract ID ' + contractID + ' has been deleted') + } + }, handleEventError: (e: Error, message: GIMessage) => { if (e.name === 'ChelErrorUnrecoverable') { sbp('okTurtles.events/emit', SERIOUS_ERROR, e) @@ -254,6 +260,10 @@ const setupChelonia = async (): Promise<*> => { if (!data) return sbp('okTurtles.events/emit', KV_EVENT, { contractID, key, data }) + }, + [NOTIFICATION_TYPE.DELETION] (contractID) { + // TODO + console.error('@@@@[NOTIFICATION] Contract ID ' + contractID + ' has been deleted') } }, handlers: { diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 3602437ba..81d2363ce 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -626,6 +626,8 @@ export default (sbp('sbp/selectors/register', { console.error(`[chelonia] Error processing kv event for ${msg.channelID} and key ${msg.key}`, msg, e) }) }] + case NOTIFICATION_TYPE.DELETION: + return [k, (msg) => v(msg.data)] default: return [k, v] } diff --git a/shared/domains/chelonia/db.js b/shared/domains/chelonia/db.js index 3def3f356..16b5aac9c 100644 --- a/shared/domains/chelonia/db.js +++ b/shared/domains/chelonia/db.js @@ -102,7 +102,7 @@ export default (sbp('sbp/selectors/register', { return sbp('chelonia/db/get', getLogHead(contractID)).then((r) => r && JSON.parse(r)) }, 'chelonia/db/deleteLatestHEADinfo': (contractID: string): Promise => { - return sbp('chelonia/db/delete', getLogHead(contractID)) + return sbp('chelonia/db/set', getLogHead(contractID), '') }, 'chelonia/db/getEntry': async function (hash: string): Promise { try { diff --git a/shared/domains/chelonia/errors.js b/shared/domains/chelonia/errors.js index 381c485ed..b082cbd92 100644 --- a/shared/domains/chelonia/errors.js +++ b/shared/domains/chelonia/errors.js @@ -34,3 +34,4 @@ export const ChelErrorSignatureError: typeof Error = ChelErrorGenerator('ChelErr export const ChelErrorSignatureKeyUnauthorized: typeof Error = ChelErrorGenerator('ChelErrorSignatureKeyUnauthorized', ChelErrorSignatureError) export const ChelErrorSignatureKeyNotFound: typeof Error = ChelErrorGenerator('ChelErrorSignatureKeyNotFound', ChelErrorSignatureError) export const ChelErrorFetchServerTimeFailed: typeof Error = ChelErrorGenerator('ChelErrorFetchServerTimeFailed') +export const ChelErrorUnexpectedHttpResponseCode: typeof Error = ChelErrorGenerator('ChelErrorUnexpectedHttpResponseCode') diff --git a/shared/pubsub.js b/shared/pubsub.js index 1c80b14d8..22d652fd5 100644 --- a/shared/pubsub.js +++ b/shared/pubsub.js @@ -96,6 +96,7 @@ export type UnsubMessage = { export const NOTIFICATION_TYPE = Object.freeze({ ENTRY: 'entry', + DELETION: 'deletion', KV: 'kv', PING: 'ping', PONG: 'pong', From fdcc33d5589221cf833cd2b9bcdc8b0a234a44af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:23:13 +0000 Subject: [PATCH 6/9] Identity deletion token & bugfixes --- backend/routes.js | 13 +++++++++---- backend/server.js | 5 +++-- frontend/controller/actions/identity.js | 20 +++++++++++++++++--- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index 6d1f035c1..a4594f58d 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -157,6 +157,10 @@ route.POST('/event', { console.info(`new user: ${name}=${deserializedHEAD.contractID} (${ip})`) } } + const deletionToken = request.headers['shelter-deletion-token'] + if (deletionToken) { + await sbp('chelonia/db/set', `_private_deletionToken_${deserializedHEAD.contractID}`, deletionToken) + } } // Store size information await sbp('backend/server/updateSize', deserializedHEAD.contractID, Buffer.byteLength(request.payload)) @@ -569,13 +573,14 @@ route.POST('/deleteContract/{hash}', { const { hash } = request.params const strategy = request.auth.strategy if (!hash || hash.startsWith('_private')) return Boom.notFound() - const owner = await sbp('chelonia/db/get', `_private_owner_${hash}`) - if (!owner) { - return Boom.notFound() - } switch (strategy) { case 'chel-shelter': { + const owner = await sbp('chelonia/db/get', `_private_owner_${hash}`) + if (!owner) { + return Boom.notFound() + } + let ultimateOwner = owner let count = 0 // Walk up the ownership tree diff --git a/backend/server.js b/backend/server.js index 99a50617b..cdbbe5829 100644 --- a/backend/server.js +++ b/backend/server.js @@ -297,17 +297,18 @@ sbp('sbp/selectors/register', { const kvKeys = await sbp('chelonia/db/get', kvIndexKey) if (kvKeys) { await kvKeys.split('\x00').map((key) => { - return sbp('chelonia/db/delete', key) + return sbp('chelonia/db/delete', `_private_kv_${cid}_${key}`) }) } + await sbp('chelonia/db/delete', kvIndexKey) await sbp('chelonia/db/delete', `_private_rid_${cid}`) await sbp('chelonia/db/delete', `_private_owner_${cid}`) await sbp('chelonia/db/delete', `_private_size_${cid}`) await sbp('chelonia/db/delete', `_private_deletionToken_${cid}`) - await removeFromIndexFactory(kvIndexKey)(cid) await removeFromIndexFactory(`_private_resources_${owner}`)(cid) + await sbp('chelonia/db/delete', `_private_hidx=${cid}#0`) await sbp('chelonia/db/set', cid, '') await sbp('chelonia/db/delete', `_private_cheloniaState_${cid}`) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 92dc8ef23..073fa5e86 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -20,7 +20,7 @@ import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import type { Key } from '../../../shared/domains/chelonia/crypto.js' -import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, generateSalt, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import { handleFetchResult } from '../utils/misc.js' import { encryptedAction, groupContractsByType, syncContractsInOrder } from './utils.js' @@ -194,6 +194,8 @@ export default (sbp('sbp/selectors/register', { const IPK = typeof wIPK === 'string' ? deserializeKey(wIPK) : wIPK const IEK = typeof wIEK === 'string' ? deserializeKey(wIEK) : wIEK + const deletionToken = generateSalt() + // Create the necessary keys to initialise the contract const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) @@ -221,6 +223,7 @@ export default (sbp('sbp/selectors/register', { const CEKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(CEK, true)) const PEKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(PEK, true)) const SAKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(SAK, true)) + const encryptedDeletionToken = encryptedOutgoingDataWithRawKey(IEK, deletionToken) // Before creating the contract, put all keys into transient store await sbp('chelonia/storeSecretKeys', @@ -232,7 +235,13 @@ export default (sbp('sbp/selectors/register', { try { await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/identity', - publishOptions, + publishOptions: { + ...publishOptions, + headers: { + ...publishOptions?.headers, + 'shelter-deletion-token': deletionToken + } + }, signingKeyId: IPKid, actionSigningKeyId: CSKid, actionEncryptionKeyId: PEKid, @@ -367,7 +376,12 @@ export default (sbp('sbp/selectors/register', { // finalPicture is set after OP_CONTRACT is sent, which is after // calling 'chelonia/out/registerContract' here. We use a getter for // `picture` so that the action sent has the correct value - attributes: { username, email, get picture () { return finalPicture } } + attributes: { + username, + email, + get picture () { return finalPicture }, + encryptedDeletionToken + } }, namespaceRegistration: username }) From 4055758e69a7c93b78a135cfb28878e4debff6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:02:43 +0000 Subject: [PATCH 7/9] Use persistent actions --- backend/routes.js | 4 +-- backend/server.js | 11 ++++-- shared/domains/chelonia/chelonia.js | 2 +- shared/domains/chelonia/persistent-actions.js | 36 +++++++++++-------- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index a4594f58d..726377362 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -621,8 +621,8 @@ route.POST('/deleteContract/{hash}', { // Authentication passed, now proceed to delete the contract and its associated // keys try { - await sbp('backend/deleteContract', hash) - return h.response() + const [id] = sbp('chelonia.persistentActions/enqueue', ['backend/deleteContract', hash]) + return h.response({ id }).code(202) } catch (e) { switch (e.name) { case 'BackendErrorNotFound': diff --git a/backend/server.js b/backend/server.js index cdbbe5829..88750a33a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,7 @@ import sbp from '@sbp/sbp' import chalk from 'chalk' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import '~/shared/domains/chelonia/chelonia.js' +import '~/shared/domains/chelonia/persistent-actions.js' import { SERVER } from '~/shared/domains/chelonia/presets.js' import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js' import { appendToIndexFactory, initDB, removeFromIndexFactory } from './database.js' @@ -271,9 +272,9 @@ sbp('sbp/selectors/register', { const resource = Buffer.from(await sbp('chelonia/db/get', resourceCid)).toString() if (resource) { if (resource.includes('previousHEAD') && resource.includes('contractID') && resource.includes('op') && resource.includes('height')) { - return sbp('backend/deleteContract', resourceCid) + return sbp('chelonia.persistentActions/enqueue', ['backend/deleteContract', resourceCid]) } else { - return sbp('backend/deleteFile', resourceCid) + return sbp('chelonia.persistentActions/enqueue', ['backend/deleteFile', resourceCid]) } } })) @@ -428,6 +429,9 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { ;(async function () { await initDB() await sbp('chelonia/configure', SERVER) + sbp('chelonia.persistentActions/configure', { + databaseKey: '_private_persistent_actions' + }) // Load the saved Chelonia state // First, get the contract index const savedStateIndex = await sbp('chelonia/db/get', '_private_cheloniaState_index') @@ -469,6 +473,9 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { }) })) } + sbp('chelonia.persistentActions/load').catch(e => { + console.error(e, 'Error loading persistent actions') + }) // https://hapi.dev/tutorials/plugins await hapi.register([ { plugin: require('./auth.js') }, diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 81d2363ce..7f52bfabc 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -627,7 +627,7 @@ export default (sbp('sbp/selectors/register', { }) }] case NOTIFICATION_TYPE.DELETION: - return [k, (msg) => v(msg.data)] + return [k, (msg) => (v: Function)(msg.data)] default: return [k, v] } diff --git a/shared/domains/chelonia/persistent-actions.js b/shared/domains/chelonia/persistent-actions.js index 946f38787..db495bd1b 100644 --- a/shared/domains/chelonia/persistent-actions.js +++ b/shared/domains/chelonia/persistent-actions.js @@ -103,7 +103,11 @@ class PersistentAction { // Schedule a retry if appropriate. if (status.nextRetry) { // Note: there should be no older active timeout to clear. - this.timer = setTimeout(() => this.attempt(), this.options.retrySeconds * 1e3) + this.timer = setTimeout(() => { + this.attempt().catch((e) => { + console.error('Error attempting persistent action', id, e) + }) + }, this.options.retrySeconds * 1e3) } } @@ -142,12 +146,11 @@ sbp('sbp/selectors/register', { // Cancels a specific action by its ID. // The action won't be retried again, but an async action cannot be aborted if its promise is stil attempting. - 'chelonia.persistentActions/cancel' (id: UUIDV4): void { + async 'chelonia.persistentActions/cancel' (id: UUIDV4): Promise { if (id in this.actionsByID) { this.actionsByID[id].cancel() delete this.actionsByID[id] - // Likely no need to await this call. - sbp('chelonia.persistentActions/save') + return await sbp('chelonia.persistentActions/save') } }, @@ -172,9 +175,14 @@ sbp('sbp/selectors/register', { this.actionsByID[action.id] = action ids.push(action.id) } - // Likely no need to await this call. - sbp('chelonia.persistentActions/save') - for (const id of ids) this.actionsByID[id].attempt() + sbp('chelonia.persistentActions/save').catch(e => { + console.error('Error saving persistent actions', e) + }) + for (const id of ids) { + this.actionsByID[id].attempt().catch((e) => { + console.error('Error attempting persistent action', id, e) + }) + } return ids }, @@ -182,9 +190,9 @@ sbp('sbp/selectors/register', { // - 'status.failedAttemptsSoFar' will still be increased upon failure. // - Does nothing if a retry is already running. // - Does nothing if the action has already been resolved, rejected or cancelled. - 'chelonia.persistentActions/forceRetry' (id: UUIDV4): void { + 'chelonia.persistentActions/forceRetry' (id: UUIDV4): void | Promise { if (id in this.actionsByID) { - this.actionsByID[id].attempt() + return this.actionsByID[id].attempt() } }, @@ -198,16 +206,16 @@ sbp('sbp/selectors/register', { // TODO: find a cleaner alternative. this.actionsByID[id].id = id } - sbp('chelonia.persistentActions/retryAll') + return sbp('chelonia.persistentActions/retryAll') }, // Retry all existing persisted actions. // TODO: add some delay between actions so as not to spam the server, // or have a way to issue them all at once in a single network call. - 'chelonia.persistentActions/retryAll' (): void { - for (const id in this.actionsByID) { - sbp('chelonia.persistentActions/forceRetry', id) - } + 'chelonia.persistentActions/retryAll' () { + return Promise.allSettled( + Object.keys(this.actionsByID).map(id => sbp('chelonia.persistentActions/forceRetry', id)) + ) }, // Updates the database version of the attempting action list. From 79472a66c61df229d2f5b1fa83b55278d17264a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:29:20 +0000 Subject: [PATCH 8/9] Better handling of deleted contracts --- frontend/setupChelonia.js | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index bb2d4438d..ac802151c 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -12,6 +12,29 @@ import manifests from './model/contracts/manifests.json' import { SETTING_CHELONIA_STATE, SETTING_CURRENT_USER } from './model/database.js' import { CHATROOM_USER_STOP_TYPING, CHATROOM_USER_TYPING, CHELONIA_STATE_MODIFIED, KV_EVENT, LOGIN_COMPLETE, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SERIOUS_ERROR } from './utils/events.js' +const handleDeletedContract = async (contractID: string) => { + const { cheloniaState } = sbp('chelonia/contract/fullState', contractID) + if (!cheloniaState) return + + await sbp('chelonia/contract/remove', contractID) + + switch (cheloniaState.type) { + /* case 'gi.contracts/chatroom': { + // TODO: Leave chatroom on our identity contract or our group, if we've + // joined. + } + case 'gi.contracts/group': { + // TODO: Leave group on our identity contract, if we've joined + } */ + case 'gi.contracts/identity': { + const ourIdentityContractId = sbp('sbp/vuex/getters').ourIdentityContractId + if (contractID === ourIdentityContractId) { + await sbp('gi.actions/identity/logout') + } + } + } +} + // This function is tasked with most common tasks related to setting up Chelonia // for Group Income. If Chelonia is running in a service worker, the service // worker should call this function. On the other hand, if Chelonia is running @@ -140,9 +163,11 @@ const setupChelonia = async (): Promise<*> => { }, hooks: { syncContractError: (e, contractID) => { - // TODO if (e?.name === 'ChelErrorUnexpectedHttpResponseCode' && e.message.startsWith('410:')) { - console.error('@@@@[syncContractError] Contract ID ' + contractID + ' has been deleted') + console.info('[syncContractError] Contract ID ' + contractID + ' has been deleted') + handleDeletedContract(contractID).catch(e => { + console.error('Error handling contract deletion', e) + }) } }, handleEventError: (e: Error, message: GIMessage) => { @@ -262,8 +287,10 @@ const setupChelonia = async (): Promise<*> => { sbp('okTurtles.events/emit', KV_EVENT, { contractID, key, data }) }, [NOTIFICATION_TYPE.DELETION] (contractID) { - // TODO - console.error('@@@@[NOTIFICATION] Contract ID ' + contractID + ' has been deleted') + console.error('[messageHandler] Contract ID ' + contractID + ' has been deleted') + handleDeletedContract(contractID).catch(e => { + console.error('Error handling contract deletion', e) + }) } }, handlers: { From 1b6fda56731dff18bd2373130d8cf30fa9bfc209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 11 Jan 2025 19:11:22 +0000 Subject: [PATCH 9/9] Identity deletion functions --- frontend/controller/actions/identity.js | 35 ++++++++++++++++++++++--- frontend/controller/app/identity.js | 25 ++++++++++++++++++ frontend/setupChelonia.js | 33 +++++++++++++++++++---- shared/domains/chelonia/chelonia.js | 2 +- shared/domains/chelonia/files.js | 2 +- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 073fa5e86..cd9b975a5 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -14,7 +14,7 @@ import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { JOINED_CHATROOM, KV_QUEUE, LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' -import { encryptedIncomingDataWithRawKey, encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' +import { encryptedIncomingData, encryptedIncomingDataWithRawKey, encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' import { rawSignedIncomingData } from '~/shared/domains/chelonia/signedData.js' import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' @@ -194,7 +194,7 @@ export default (sbp('sbp/selectors/register', { const IPK = typeof wIPK === 'string' ? deserializeKey(wIPK) : wIPK const IEK = typeof wIEK === 'string' ? deserializeKey(wIEK) : wIEK - const deletionToken = generateSalt() + const deletionToken = 'deletionToken' + generateSalt() // Create the necessary keys to initialise the contract const CSK = keygen(EDWARDS25519SHA512BATCH) @@ -380,7 +380,7 @@ export default (sbp('sbp/selectors/register', { username, email, get picture () { return finalPicture }, - encryptedDeletionToken + encryptedDeletionToken: encryptedDeletionToken.serialize('encryptedDeletionToken') } }, namespaceRegistration: username @@ -983,6 +983,35 @@ export default (sbp('sbp/selectors/register', { // And remove transient keys, which require a user password sbp('chelonia/clearTransientSecretKeys', [oldIEKid, oldIPKid, IEKid, IPKid]) }, + 'gi.actions/identity/delete': async ({ + contractID, + transientSecretKeys, + oldKeysAnchorCid + }: { + contractID: string, + transientSecretKeys: Secret, + oldKeysAnchorCid: string + }) => { + const state = sbp('chelonia/contract/state', contractID) + if (!state?.attributes?.encryptedDeletionToken) { + throw new Error('Missing encrypted deletion token') + } + + const transientSecretKeysEntries = transientSecretKeys.valueOf().map( + k => ([keyId(k), deserializeKey(k)]) + ) + const encryptedDeletionToken = state.attributes.encryptedDeletionToken + + if (oldKeysAnchorCid) { + const IEK = transientSecretKeysEntries[0][1] + await processOldIekList(contractID, oldKeysAnchorCid, IEK) + } + + const token = encryptedIncomingData(contractID, state, encryptedDeletionToken, NaN, Object.fromEntries(transientSecretKeysEntries), 'encryptedDeletionToken') + await sbp('chelonia/out/deleteContract', contractID, { + [contractID]: { token: new Secret(token.valueOf()) } + }) + }, ...encryptedAction('gi.actions/identity/saveFileDeleteToken', L('Failed to save delete tokens for the attachments.')), ...encryptedAction('gi.actions/identity/removeFileDeleteToken', L('Failed to remove delete tokens for the attachments.')), ...encryptedAction('gi.actions/identity/setGroupAttributes', L('Failed to set group attributes.')) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index dc0bc521d..68d573745 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -503,5 +503,30 @@ export default (sbp('sbp/selectors/register', { newIEK: new Secret(newIEK), updateToken: new Secret(updateToken) }) + }, + 'gi.app/identity/delete': async (username: string, contractID: string, wPassword: Secret) => { + const password = wPassword?.valueOf() + const transientSecretKeys = [] + let oldKeysAnchorCid + + // If we're creating a new session, here we derive the IEK. This key (not + // the password) will be passed to the service worker. + if (password) { + try { + const [salt, cid] = await sbp('gi.app/identity/retrieveSalt', username, wPassword) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) + transientSecretKeys.push(IEK) + oldKeysAnchorCid = cid + } catch (e) { + console.error('caught error calling retrieveSalt:', e) + throw new GIErrorUIRuntimeError(L('Incorrect username or password')) + } + } + + await sbp('gi.actions/identity/delete', { + contractID, + transientSecretKeys: new Secret(transientSecretKeys.map(k => serializeKey(k, true))), + oldKeysAnchorCid + }) } }): string[]) diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index ac802151c..3cacf805b 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -13,25 +13,48 @@ import { SETTING_CHELONIA_STATE, SETTING_CURRENT_USER } from './model/database.j import { CHATROOM_USER_STOP_TYPING, CHATROOM_USER_TYPING, CHELONIA_STATE_MODIFIED, KV_EVENT, LOGIN_COMPLETE, LOGOUT, OFFLINE, ONLINE, RECONNECTING, RECONNECTION_FAILED, SERIOUS_ERROR } from './utils/events.js' const handleDeletedContract = async (contractID: string) => { - const { cheloniaState } = sbp('chelonia/contract/fullState', contractID) + const { cheloniaState, contractState } = sbp('chelonia/contract/fullState', contractID) if (!cheloniaState) return await sbp('chelonia/contract/remove', contractID) + const rootGetters = sbp('state/vuex/getters') + switch (cheloniaState.type) { - /* case 'gi.contracts/chatroom': { + case 'gi.contracts/chatroom': { // TODO: Leave chatroom on our identity contract or our group, if we've // joined. + break } case 'gi.contracts/group': { - // TODO: Leave group on our identity contract, if we've joined - } */ + const identityContractID = rootGetters.ourIdentityContractId + const currentIdentityState = rootGetters.currentIdentityState + + if (!!currentIdentityState.groups[contractID] && !currentIdentityState.groups[contractID]?.hasLeft) { + await sbp('gi.actions/identity/leaveGroup', { + contractID: identityContractID, + data: { + groupContractID: contractID, + reference: contractState.profiles?.[identityContractID]?.reference + } + }).catch(e => { + console.warn(`[handleDeletedContract] ${e.name} thrown by gi.actions/identity/leaveGroup ${identityContractID} for ${contractID}:`, e) + }) + } + + break + } case 'gi.contracts/identity': { - const ourIdentityContractId = sbp('sbp/vuex/getters').ourIdentityContractId + const ourIdentityContractId = sbp('state/vuex/getters').ourIdentityContractId + if (contractID === ourIdentityContractId) { await sbp('gi.actions/identity/logout') } + + break } + default: + console.warn('[handleDeletedContract] Received contract deletion notification for contract ID of unknown type', contractID, cheloniaState.type) } } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 7f52bfabc..ff2c328df 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -1190,7 +1190,7 @@ export default (sbp('sbp/selectors/register', { ['authorization', hasToken // $FlowFixMe[incompatible-type] - ? `bearer ${credentials[cid].token}` + ? `bearer ${(credentials[cid].token: any).valueOf()}` // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-call] : buildShelterAuthorizationHeader.call(this, credentials[cid].billableContractID)] diff --git a/shared/domains/chelonia/files.js b/shared/domains/chelonia/files.js index 38b097366..42f12bbce 100644 --- a/shared/domains/chelonia/files.js +++ b/shared/domains/chelonia/files.js @@ -385,7 +385,7 @@ export default (sbp('sbp/selectors/register', { ['authorization', hasToken // $FlowFixMe[incompatible-type] - ? `bearer ${credentials[cid].token}` + ? `bearer ${(credentials[cid].token: any).valueOf()}` // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-call] : buildShelterAuthorizationHeader.call(this, credentials[cid].billableContractID)]