From 1515258d34ea6288e799c9acb3a5bc0c9c6a7e18 Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Mon, 1 Jun 2026 14:26:00 +0300 Subject: [PATCH 1/4] bucket labels --- package-lock.json | 10 + package.json | 1 + src/@types/commands.ts | 9 + .../core/handler/coreHandlersRegistry.ts | 5 + .../core/handler/persistentStorage.ts | 77 ++++- .../httpRoutes/persistentStorage.ts | 32 +++ .../PersistentStorageFactory.ts | 93 +++++- .../PersistentStorageLocalFS.ts | 13 +- .../persistentStorage/PersistentStorageS3.ts | 3 +- .../integration/persistentStorage.test.ts | 264 ++++++++++++++++++ src/utils/constants.ts | 2 + 11 files changed, 493 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7da365035..476e0ab8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "stream-concat": "^1.0.0", "tar": "^7.5.11", "uint8arrays": "^4.0.6", + "unique-names-generator": "^4.7.1", "url-join": "^5.0.0", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", @@ -19258,6 +19259,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/unique-names-generator": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz", + "integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", diff --git a/package.json b/package.json index d32e99312..0a85c3f32 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "stream-concat": "^1.0.0", "tar": "^7.5.11", "uint8arrays": "^4.0.6", + "unique-names-generator": "^4.7.1", "url-join": "^5.0.0", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", diff --git a/src/@types/commands.ts b/src/@types/commands.ts index badc755f3..a203bfed3 100644 --- a/src/@types/commands.ts +++ b/src/@types/commands.ts @@ -344,6 +344,15 @@ export interface PersistentStorageCreateBucketCommand extends Command { signature: string nonce: string accessLists: AccessList[] + label?: string +} + +export interface PersistentStorageUpdateBucketCommand extends Command { + consumerAddress: string + signature: string + nonce: string + bucketId: string + label?: string } export interface PersistentStorageGetBucketsCommand extends Command { diff --git a/src/components/core/handler/coreHandlersRegistry.ts b/src/components/core/handler/coreHandlersRegistry.ts index 3b0aee4d4..338f7d784 100644 --- a/src/components/core/handler/coreHandlersRegistry.ts +++ b/src/components/core/handler/coreHandlersRegistry.ts @@ -53,6 +53,7 @@ import { PersistentStorageGetBucketsHandler, PersistentStorageGetFileObjectHandler, PersistentStorageListFilesHandler, + PersistentStorageUpdateBucketHandler, PersistentStorageUploadFileHandler } from './persistentStorage.js' import { GetAccessListHandler, SearchAccessListHandler } from './accessListHandler.js' @@ -181,6 +182,10 @@ export class CoreHandlersRegistry { PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, new PersistentStorageCreateBucketHandler(node) ) + this.registerCoreHandler( + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + new PersistentStorageUpdateBucketHandler(node) + ) this.registerCoreHandler( PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS, new PersistentStorageGetBucketsHandler(node) diff --git a/src/components/core/handler/persistentStorage.ts b/src/components/core/handler/persistentStorage.ts index a3ff9ae8a..c27b785b2 100644 --- a/src/components/core/handler/persistentStorage.ts +++ b/src/components/core/handler/persistentStorage.ts @@ -5,6 +5,7 @@ import type { PersistentStorageGetBucketsCommand, PersistentStorageGetFileObjectCommand, PersistentStorageListFilesCommand, + PersistentStorageUpdateBucketCommand, PersistentStorageUploadFileCommand } from '../../../@types/commands.js' import { @@ -23,6 +24,21 @@ import { } from '../../httpRoutes/validateCommands.js' import { CommandHandler } from './handler.js' +const MAX_BUCKET_LABEL_LENGTH = 256 + +function validateOptionalLabel(label: unknown): ValidateParams | null { + if (label === undefined || label === null) return null + if (typeof label !== 'string') { + return buildInvalidRequestMessage('Invalid parameter: "label" must be a string') + } + if (label.length > MAX_BUCKET_LABEL_LENGTH) { + return buildInvalidRequestMessage( + `Invalid parameter: "label" must be at most ${MAX_BUCKET_LABEL_LENGTH} characters` + ) + } + return null +} + function requirePersistentStorage(handler: CommandHandler): PersistentStorageFactory { const node = handler.getOceanNode() as any if (!node.getPersistentStorage) { @@ -44,6 +60,8 @@ export class PersistentStorageCreateBucketHandler extends CommandHandler { 'Invalid parameter: "accessLists" must be an array of objects' ) } + const labelError = validateOptionalLabel(command.label) + if (labelError) return labelError return { valid: true } } @@ -97,7 +115,11 @@ export class PersistentStorageCreateBucketHandler extends CommandHandler { } } - const result = await storage.createNewBucket(task.accessLists, ownerNormalized) + const result = await storage.createNewBucket( + task.accessLists, + ownerNormalized, + task.label + ) return { stream: Readable.from(JSON.stringify(result)), status: { httpStatus: 200, error: null } @@ -110,6 +132,59 @@ export class PersistentStorageCreateBucketHandler extends CommandHandler { } } +export class PersistentStorageUpdateBucketHandler extends CommandHandler { + validate(command: PersistentStorageUpdateBucketCommand): ValidateParams { + const base = validateCommandParameters(command, ['bucketId']) + if (!base.valid) return base + if (!command.bucketId || typeof command.bucketId !== 'string') { + return buildInvalidRequestMessage('Invalid parameter: "bucketId" must be a string') + } + const labelError = validateOptionalLabel(command.label) + if (labelError) return labelError + return { valid: true } + } + + async handle(task: PersistentStorageUpdateBucketCommand): Promise { + const validationResponse = await this.verifyParamsAndRateLimits(task) + if (this.shouldDenyTaskHandling(validationResponse)) return validationResponse + + const isAuthRequestValid = await this.validateTokenOrSignature( + task.authorization, + task.consumerAddress, + task.nonce, + task.signature, + task.command + ) + if (isAuthRequestValid.status.httpStatus !== 200) return isAuthRequestValid + + try { + const storage = requirePersistentStorage(this) + const ownerNormalized = task.consumerAddress + ? getAddress(task.consumerAddress) + : getAddress(await this.getAddressFromToken(task.authorization)) + const label = await storage.updateBucketLabel( + task.bucketId, + task.label ?? null, + ownerNormalized + ) + return { + stream: Readable.from(JSON.stringify({ bucketId: task.bucketId, label })), + status: { httpStatus: 200, error: null } + } + } catch (e) { + if (e instanceof PersistentStorageAccessDeniedError) { + return { stream: null, status: { httpStatus: 403, error: e.message } } + } + const message = e instanceof Error ? e.message : String(e) + if (message.toLowerCase().includes('not found')) { + return { stream: null, status: { httpStatus: 404, error: message } } + } + CORE_LOGGER.error(`PersistentStorageUpdateBucketHandler error: ${message}`) + return { stream: null, status: { httpStatus: 500, error: message } } + } + } +} + export class PersistentStorageGetBucketsHandler extends CommandHandler { validate(command: PersistentStorageGetBucketsCommand): ValidateParams { const base = validateCommandParameters(command, ['owner']) diff --git a/src/components/httpRoutes/persistentStorage.ts b/src/components/httpRoutes/persistentStorage.ts index c99ad3d76..f4004f017 100644 --- a/src/components/httpRoutes/persistentStorage.ts +++ b/src/components/httpRoutes/persistentStorage.ts @@ -11,6 +11,7 @@ import { PersistentStorageGetBucketsHandler, PersistentStorageGetFileObjectHandler, PersistentStorageListFilesHandler, + PersistentStorageUpdateBucketHandler, PersistentStorageUploadFileHandler } from '../core/handler/persistentStorage.js' @@ -43,6 +44,37 @@ persistentStorageRoutes.post( } ) +// Update bucket (rename / set label) +persistentStorageRoutes.patch( + `${SERVICES_API_BASE_PATH}/persistentStorage/buckets/:bucketId`, + express.json(), + async (req, res) => { + try { + const response = await new PersistentStorageUpdateBucketHandler( + req.oceanNode + ).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress: req.query.consumerAddress as string, + signature: req.query.signature as string, + nonce: req.query.nonce as string, + bucketId: req.params.bucketId, + label: req.body?.label, + authorization: req.headers?.authorization, + caller: req.caller + } as any) + if (!response.stream) { + res.status(response.status.httpStatus).send(response.status.error) + return + } + const payload = await streamToObject(response.stream as Readable) + res.status(200).json(payload) + } catch (error) { + HTTP_LOGGER.error(`PersistentStorage update bucket error: ${error}`) + res.status(500).send('Internal Server Error') + } + } +) + // List buckets for an owner (then filtered by ACL in handler) persistentStorageRoutes.get( `${SERVICES_API_BASE_PATH}/persistentStorage/buckets`, diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index d0ee58d5c..8299972b8 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -41,6 +41,7 @@ export type BucketRow = { owner: string accessListJson: string createdAt: number + label: string | null } export interface PersistentStorageFileInfo { @@ -54,6 +55,7 @@ export type CreateBucketResult = { bucketId: string owner: string accessList: AccessList[] + label?: string | null } /** Bucket metadata from registry (list APIs and internal filtering). */ @@ -62,6 +64,7 @@ export type PersistentStorageBucketRecord = { owner: string createdAt: number accessLists: AccessList[] + label?: string | null } export abstract class PersistentStorageFactory { @@ -82,7 +85,8 @@ export abstract class PersistentStorageFactory { bucketId TEXT PRIMARY KEY, owner TEXT NOT NULL, accessListJson TEXT NOT NULL, - createdAt INTEGER NOT NULL + createdAt INTEGER NOT NULL, + label TEXT ); ` this.dbReadyPromise = new Promise((resolve, reject) => { @@ -91,8 +95,32 @@ export abstract class PersistentStorageFactory { reject(err) return } - this.dbReady = true - resolve() + // Add the `label` column to databases created before it existed. + this.db.all( + `PRAGMA table_info(persistent_storage_buckets)`, + (pragmaErr, columns: Array<{ name: string }>) => { + if (pragmaErr) { + reject(pragmaErr) + return + } + if (columns.some((col) => col.name === 'label')) { + this.dbReady = true + resolve() + return + } + this.db.run( + `ALTER TABLE persistent_storage_buckets ADD COLUMN label TEXT`, + (alterErr) => { + if (alterErr && !/duplicate column name/i.test(alterErr.message)) { + reject(alterErr) + return + } + this.dbReady = true + resolve() + } + ) + } + ) }) }) } @@ -123,7 +151,8 @@ export abstract class PersistentStorageFactory { public abstract createNewBucket( accessList: AccessList[], - owner: string + owner: string, + label?: string ): Promise public abstract listFiles( @@ -197,7 +226,8 @@ export abstract class PersistentStorageFactory { bucketId: row.bucketId, owner: row.owner, createdAt: row.createdAt, - accessLists: parseBucketAccessListsJson(row.accessListJson) + accessLists: parseBucketAccessListsJson(row.accessListJson), + label: row.label ?? null })) } @@ -210,17 +240,19 @@ export abstract class PersistentStorageFactory { bucketId: string, owner: string, accessListJson: string, - createdAt: number + createdAt: number, + label: string | null ): Promise { + // ON CONFLICT does not touch label, so a re-create never clobbers a rename. const sql = ` - INSERT INTO persistent_storage_buckets (bucketId, owner, accessListJson, createdAt) - VALUES (?, ?, ?, ?) + INSERT INTO persistent_storage_buckets (bucketId, owner, accessListJson, createdAt, label) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(bucketId) DO UPDATE SET accessListJson=excluded.accessListJson; ` return this.ensureDbReady().then( () => new Promise((resolve, reject) => { - this.db.run(sql, [bucketId, owner, accessListJson, createdAt], (err) => { + this.db.run(sql, [bucketId, owner, accessListJson, createdAt, label], (err) => { if (err) reject(err) else resolve() }) @@ -229,7 +261,7 @@ export abstract class PersistentStorageFactory { } dbGetBucket(bucketId: string): Promise { - const sql = `SELECT bucketId, owner, accessListJson, createdAt FROM persistent_storage_buckets WHERE bucketId = ?` + const sql = `SELECT bucketId, owner, accessListJson, createdAt, label FROM persistent_storage_buckets WHERE bucketId = ?` return this.ensureDbReady().then( () => new Promise((resolve, reject) => { @@ -242,7 +274,7 @@ export abstract class PersistentStorageFactory { } dbListBucketsByOwner(owner: string): Promise { - const sql = `SELECT bucketId, owner, accessListJson, createdAt FROM persistent_storage_buckets WHERE owner = ? ORDER BY createdAt ASC` + const sql = `SELECT bucketId, owner, accessListJson, createdAt, label FROM persistent_storage_buckets WHERE owner = ? ORDER BY createdAt ASC` return this.ensureDbReady().then( () => new Promise((resolve, reject) => { @@ -267,6 +299,23 @@ export abstract class PersistentStorageFactory { ) } + dbUpdateBucketLabel( + bucketId: string, + owner: string, + label: string | null + ): Promise { + const sql = `UPDATE persistent_storage_buckets SET label = ? WHERE bucketId = ? AND owner = ?` + return this.ensureDbReady().then( + () => + new Promise((resolve, reject) => { + this.db.run(sql, [label, bucketId, owner], function (this: RunResult, err) { + if (err) reject(err) + else resolve(this.changes === 1) + }) + }) + ) + } + isAllowed(consumerAddress: string, accessLists: AccessList[]): Promise { return checkAddressOnAccessList(consumerAddress, accessLists, this.node) } @@ -288,6 +337,28 @@ export abstract class PersistentStorageFactory { throw new PersistentStorageAccessDeniedError() } } + + public async updateBucketLabel( + bucketId: string, + label: string | null, + owner: string + ): Promise { + this.validateBucket(bucketId) + const bucket = await this.getBucket(bucketId) + if (!bucket) { + throw new Error(`Bucket not found: ${bucketId}`) + } + if (normalizeWeb3Address(owner) !== normalizeWeb3Address(bucket.owner)) { + throw new PersistentStorageAccessDeniedError() + } + const normalized = label && label.trim() ? label.trim() : null + await this.dbUpdateBucketLabel( + bucketId, + normalizeWeb3Address(bucket.owner), + normalized + ) + return normalized + } } /** diff --git a/src/components/persistentStorage/PersistentStorageLocalFS.ts b/src/components/persistentStorage/PersistentStorageLocalFS.ts index 4c1dec0bc..65017465c 100644 --- a/src/components/persistentStorage/PersistentStorageLocalFS.ts +++ b/src/components/persistentStorage/PersistentStorageLocalFS.ts @@ -3,6 +3,7 @@ import fsp from 'fs/promises' import path from 'path' import { pipeline } from 'stream/promises' import { randomUUID } from 'crypto' +import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator' import type { AccessList } from '../../@types/AccessList.js' import type { @@ -94,10 +95,15 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { async createNewBucket( accessList: AccessList[], - owner: string + owner: string, + label?: string ): Promise { const bucketId = randomUUID() const createdAt = Math.floor(Date.now() / 1000) + const finalLabel = + label && label.trim() + ? label.trim() + : uniqueNamesGenerator({ dictionaries: [adjectives, animals], separator: '-' }) const path = this.bucketPath(bucketId) CORE_LOGGER.debug(`Creating ${path} folder for new bucket`) await fsp.mkdir(path) @@ -105,10 +111,11 @@ export class PersistentStorageLocalFS extends PersistentStorageFactory { bucketId, owner, JSON.stringify(accessList ?? []), - createdAt + createdAt, + finalLabel ) - return { bucketId, owner, accessList } + return { bucketId, owner, accessList, label: finalLabel } } async listFiles( diff --git a/src/components/persistentStorage/PersistentStorageS3.ts b/src/components/persistentStorage/PersistentStorageS3.ts index bd4cac5ee..d0bf4ed99 100644 --- a/src/components/persistentStorage/PersistentStorageS3.ts +++ b/src/components/persistentStorage/PersistentStorageS3.ts @@ -34,7 +34,8 @@ export class PersistentStorageS3 extends PersistentStorageFactory { // eslint-disable-next-line require-await async createNewBucket( accessList: AccessList[], - _owner: string + _owner: string, + _label?: string ): Promise { throw new Error('PersistentStorageS3 is not implemented yet') } diff --git a/src/test/integration/persistentStorage.test.ts b/src/test/integration/persistentStorage.test.ts index 7383d6d30..5f425efcc 100644 --- a/src/test/integration/persistentStorage.test.ts +++ b/src/test/integration/persistentStorage.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai' import fsp from 'fs/promises' import os from 'os' import path from 'path' +import { randomUUID } from 'crypto' import { Readable } from 'stream' import { getAddress, JsonRpcProvider, Signer } from 'ethers' @@ -12,6 +13,7 @@ import { PersistentStorageGetBucketsHandler, PersistentStorageGetFileObjectHandler, PersistentStorageListFilesHandler, + PersistentStorageUpdateBucketHandler, PersistentStorageUploadFileHandler } from '../../components/core/handler/persistentStorage.js' import { StatusHandler } from '../../components/core/handler/statusHandler.js' @@ -648,6 +650,268 @@ describe('********** Persistent storage handlers (integration)', functio expect(validation.reason).to.contain('accessLists') }) + it('creates a bucket with a label and returns it from getBuckets', async () => { + const consumerAddress = await consumer.getAddress() + await sleep(1000) + let nonce = Date.now().toString() + let messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + let signature = await safeSign(consumer, messageHashBytes) + const label = 'my-dataset-bucket' + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + label, + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const created = await streamToObject(createRes.stream as Readable) + expect(created.label).to.equal(label) + const bucketId = created.bucketId as string + + await sleep(1000) + nonce = Date.now().toString() + messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS + ) + signature = await safeSign(consumer, messageHashBytes) + const listRes = await new PersistentStorageGetBucketsHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS, + consumerAddress, + signature, + nonce, + owner: consumerAddress, + authorization: undefined + } as any) + expect(listRes.status.httpStatus).to.equal(200) + const buckets = await streamToObject(listRes.stream as Readable) + const found = buckets.find((b: { bucketId: string }) => b.bucketId === bucketId) + expect(found).to.be.an('object') + expect(found.label).to.equal(label) + }) + + it('assigns a friendly default name when no label is provided', async () => { + const consumerAddress = await consumer.getAddress() + await sleep(1000) + const nonce = Date.now().toString() + const messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + const signature = await safeSign(consumer, messageHashBytes) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const created = await streamToObject(createRes.stream as Readable) + expect(created.label).to.be.a('string') + expect(created.label.length).to.be.greaterThan(0) + }) + + it('owner can rename a bucket and getBuckets reflects the new name', async () => { + const consumerAddress = await consumer.getAddress() + await sleep(1000) + let nonce = Date.now().toString() + let messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + let signature = await safeSign(consumer, messageHashBytes) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + label: 'before', + authorization: undefined + } as any) + expect(createRes.status.httpStatus).to.equal(200) + const created = await streamToObject(createRes.stream as Readable) + const bucketId = created.bucketId as string + + await sleep(1000) + nonce = Date.now().toString() + messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET + ) + signature = await safeSign(consumer, messageHashBytes) + const updateRes = await new PersistentStorageUpdateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress, + signature, + nonce, + bucketId, + label: 'after', + authorization: undefined + } as any) + expect(updateRes.status.httpStatus).to.equal(200) + const updated = await streamToObject(updateRes.stream as Readable) + expect(updated.label).to.equal('after') + + await sleep(1000) + nonce = Date.now().toString() + messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS + ) + signature = await safeSign(consumer, messageHashBytes) + const listRes = await new PersistentStorageGetBucketsHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS, + consumerAddress, + signature, + nonce, + owner: consumerAddress, + authorization: undefined + } as any) + const buckets = await streamToObject(listRes.stream as Readable) + const found = buckets.find((b: { bucketId: string }) => b.bucketId === bucketId) + expect(found.label).to.equal('after') + }) + + it('renaming with an empty label clears the name', async () => { + const consumerAddress = await consumer.getAddress() + await sleep(1000) + let nonce = Date.now().toString() + let messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + let signature = await safeSign(consumer, messageHashBytes) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [], + label: 'temporary', + authorization: undefined + } as any) + const created = await streamToObject(createRes.stream as Readable) + const bucketId = created.bucketId as string + + await sleep(1000) + nonce = Date.now().toString() + messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET + ) + signature = await safeSign(consumer, messageHashBytes) + const updateRes = await new PersistentStorageUpdateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress, + signature, + nonce, + bucketId, + label: '', + authorization: undefined + } as any) + expect(updateRes.status.httpStatus).to.equal(200) + const updated = await streamToObject(updateRes.stream as Readable) + expect(updated.label).to.equal(null) + }) + + it('non-owner cannot rename a bucket (403)', async () => { + // consumer owns the bucket (with an ACL); a different wallet must not rename it, + // even if it were on the access list — rename is owner-only. + const consumerAddress = await consumer.getAddress() + await sleep(1000) + let nonce = Date.now().toString() + let messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET + ) + let signature = await safeSign(consumer, messageHashBytes) + const createRes = await new PersistentStorageCreateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + consumerAddress, + signature, + nonce, + accessLists: [bucketAllowList], + authorization: undefined + } as any) + const created = await streamToObject(createRes.stream as Readable) + const bucketId = created.bucketId as string + + const forbiddenConsumerAddress = await forbiddenConsumer.getAddress() + nonce = Date.now().toString() + messageHashBytes = createHashForSignature( + forbiddenConsumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET + ) + signature = await safeSign(forbiddenConsumer, messageHashBytes) + const updateRes = await new PersistentStorageUpdateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress: forbiddenConsumerAddress, + signature, + nonce, + bucketId, + label: 'hijacked', + authorization: undefined + } as any) + expect(updateRes.status.httpStatus).to.equal(403) + expect(updateRes.status.error).to.contain('not allowed') + }) + + it('rename returns 404 for an unknown bucket', async () => { + const consumerAddress = await consumer.getAddress() + await sleep(1000) + const nonce = Date.now().toString() + const messageHashBytes = createHashForSignature( + consumerAddress, + nonce, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET + ) + const signature = await safeSign(consumer, messageHashBytes) + const updateRes = await new PersistentStorageUpdateBucketHandler(oceanNode).handle({ + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress, + signature, + nonce, + bucketId: randomUUID(), + label: 'ghost', + authorization: undefined + } as any) + expect(updateRes.status.httpStatus).to.equal(404) + }) + + it('rename validate rejects an over-long label', async () => { + const validation = await new PersistentStorageUpdateBucketHandler(oceanNode).validate( + { + command: PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, + consumerAddress: await consumer.getAddress(), + signature: 'x', + nonce: '1', + bucketId: randomUUID(), + label: 'a'.repeat(257) + } as any + ) + expect(validation.valid).to.equal(false) + expect(validation.reason).to.contain('label') + }) + it('returns error when persistent storage is disabled', async () => { const disabledConfig = { ...config, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 55e080372..4687b7cfb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -40,6 +40,7 @@ export const PROTOCOL_COMMANDS = { GET_LOGS: 'getLogs', JOBS: 'jobs', PERSISTENT_STORAGE_CREATE_BUCKET: 'persistentStorageCreateBucket', + PERSISTENT_STORAGE_UPDATE_BUCKET: 'persistentStorageUpdateBucket', PERSISTENT_STORAGE_GET_BUCKETS: 'persistentStorageGetBuckets', PERSISTENT_STORAGE_LIST_FILES: 'persistentStorageListFiles', PERSISTENT_STORAGE_UPLOAD_FILE: 'persistentStorageUploadFile', @@ -89,6 +90,7 @@ export const SUPPORTED_PROTOCOL_COMMANDS: string[] = [ PROTOCOL_COMMANDS.GET_LOGS, PROTOCOL_COMMANDS.JOBS, PROTOCOL_COMMANDS.PERSISTENT_STORAGE_CREATE_BUCKET, + PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPDATE_BUCKET, PROTOCOL_COMMANDS.PERSISTENT_STORAGE_GET_BUCKETS, PROTOCOL_COMMANDS.PERSISTENT_STORAGE_LIST_FILES, PROTOCOL_COMMANDS.PERSISTENT_STORAGE_UPLOAD_FILE, From f73fccccb88e8e819e7158831f745eba0f7b8e0c Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Mon, 1 Jun 2026 14:34:57 +0300 Subject: [PATCH 2/4] compact migration --- .../PersistentStorageFactory.ts | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index 8299972b8..4da1b788f 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -95,30 +95,13 @@ export abstract class PersistentStorageFactory { reject(err) return } - // Add the `label` column to databases created before it existed. - this.db.all( - `PRAGMA table_info(persistent_storage_buckets)`, - (pragmaErr, columns: Array<{ name: string }>) => { - if (pragmaErr) { - reject(pragmaErr) - return - } - if (columns.some((col) => col.name === 'label')) { - this.dbReady = true - resolve() - return - } - this.db.run( - `ALTER TABLE persistent_storage_buckets ADD COLUMN label TEXT`, - (alterErr) => { - if (alterErr && !/duplicate column name/i.test(alterErr.message)) { - reject(alterErr) - return - } - this.dbReady = true - resolve() - } - ) + // Migration: add the label column if it doesn't exist + this.db.run( + `ALTER TABLE persistent_storage_buckets ADD COLUMN label TEXT`, + () => { + // Ignore error if column already exists + this.dbReady = true + resolve() } ) }) From 4a972aaf92c3bf13cc454fbe7a3600bb6396bee1 Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Tue, 2 Jun 2026 08:33:06 +0300 Subject: [PATCH 3/4] same bucket label if patch undefined --- src/components/core/handler/persistentStorage.ts | 2 +- .../persistentStorage/PersistentStorageFactory.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/core/handler/persistentStorage.ts b/src/components/core/handler/persistentStorage.ts index c27b785b2..02b89fe0a 100644 --- a/src/components/core/handler/persistentStorage.ts +++ b/src/components/core/handler/persistentStorage.ts @@ -164,7 +164,7 @@ export class PersistentStorageUpdateBucketHandler extends CommandHandler { : getAddress(await this.getAddressFromToken(task.authorization)) const label = await storage.updateBucketLabel( task.bucketId, - task.label ?? null, + task.label, ownerNormalized ) return { diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index 4da1b788f..b00b547b0 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -323,7 +323,7 @@ export abstract class PersistentStorageFactory { public async updateBucketLabel( bucketId: string, - label: string | null, + label: string | null | undefined, owner: string ): Promise { this.validateBucket(bucketId) @@ -334,6 +334,10 @@ export abstract class PersistentStorageFactory { if (normalizeWeb3Address(owner) !== normalizeWeb3Address(bucket.owner)) { throw new PersistentStorageAccessDeniedError() } + // Omitted label leaves the name unchanged (PATCH semantics); null/'' clears it. + if (label === undefined) { + return bucket.label ?? null + } const normalized = label && label.trim() ? label.trim() : null await this.dbUpdateBucketLabel( bucketId, From 432778b3a4599ce94f4a5e7239433f228fcc3cf7 Mon Sep 17 00:00:00 2001 From: giurgiur99 Date: Tue, 2 Jun 2026 13:47:42 +0300 Subject: [PATCH 4/4] fix review comments --- .../persistentStorage/PersistentStorageFactory.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/persistentStorage/PersistentStorageFactory.ts b/src/components/persistentStorage/PersistentStorageFactory.ts index b00b547b0..571524563 100644 --- a/src/components/persistentStorage/PersistentStorageFactory.ts +++ b/src/components/persistentStorage/PersistentStorageFactory.ts @@ -98,8 +98,13 @@ export abstract class PersistentStorageFactory { // Migration: add the label column if it doesn't exist this.db.run( `ALTER TABLE persistent_storage_buckets ADD COLUMN label TEXT`, - () => { - // Ignore error if column already exists + (alterErr) => { + // Ignore "duplicate column name" (expected once the column exists); + // surface any other failure instead of starting with a broken schema. + if (alterErr && !/duplicate column name/i.test(alterErr.message)) { + reject(alterErr) + return + } this.dbReady = true resolve() } @@ -339,11 +344,14 @@ export abstract class PersistentStorageFactory { return bucket.label ?? null } const normalized = label && label.trim() ? label.trim() : null - await this.dbUpdateBucketLabel( + const updated = await this.dbUpdateBucketLabel( bucketId, normalizeWeb3Address(bucket.owner), normalized ) + if (!updated) { + throw new Error(`Bucket not found: ${bucketId}`) + } return normalized } }