diff --git a/apps/api/src/ipns/ipns.service.spec.ts b/apps/api/src/ipns/ipns.service.spec.ts index f301917e2a..c8dac59f35 100644 --- a/apps/api/src/ipns/ipns.service.spec.ts +++ b/apps/api/src/ipns/ipns.service.spec.ts @@ -99,14 +99,14 @@ describe('IpnsService', () => { it('should publish record for new folder successfully', async () => { mockFolderIpnsRepo.findOne.mockResolvedValue(null); - mockFolderIpnsRepo.create.mockReturnValue({ ...mockFolderEntity, sequenceNumber: '0' }); - mockFolderIpnsRepo.save.mockResolvedValue({ ...mockFolderEntity, sequenceNumber: '0' }); + mockFolderIpnsRepo.create.mockReturnValue({ ...mockFolderEntity, sequenceNumber: '1' }); + mockFolderIpnsRepo.save.mockResolvedValue({ ...mockFolderEntity, sequenceNumber: '1' }); const result = await service.publishRecord(testUserId, createDto()); expect(result.success).toBe(true); expect(result.ipnsName).toBe(testIpnsName); - expect(result.sequenceNumber).toBe('0'); + expect(result.sequenceNumber).toBe('1'); expect(mockDelegatedRoutingClient.publish).toHaveBeenCalledWith( testIpnsName, expect.any(Uint8Array) @@ -141,13 +141,13 @@ describe('IpnsService', () => { mockFolderIpnsRepo.findOne.mockResolvedValue(null); mockFolderIpnsRepo.create.mockReturnValue({ ...mockFolderEntity, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: null, keyEpoch: null, }); mockFolderIpnsRepo.save.mockResolvedValue({ ...mockFolderEntity, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: null, keyEpoch: null, }); @@ -169,13 +169,13 @@ describe('IpnsService', () => { mockFolderIpnsRepo.findOne.mockResolvedValue(null); mockFolderIpnsRepo.create.mockReturnValue({ ...mockFolderEntity, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: Buffer.from(testEncryptedIpnsPrivateKey, 'hex'), keyEpoch: null, }); mockFolderIpnsRepo.save.mockResolvedValue({ ...mockFolderEntity, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: Buffer.from(testEncryptedIpnsPrivateKey, 'hex'), keyEpoch: null, }); @@ -314,7 +314,7 @@ describe('IpnsService', () => { userId: testUserId, ipnsName: testIpnsName, latestCid: testMetadataCid, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: Buffer.from(testEncryptedIpnsPrivateKey, 'hex'), keyEpoch: testKeyEpoch, isRoot: false, @@ -335,7 +335,7 @@ describe('IpnsService', () => { userId: testUserId, ipnsName: testIpnsName, latestCid: testMetadataCid, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: Buffer.from(testEncryptedIpnsPrivateKey, 'hex'), keyEpoch: testKeyEpoch, isRoot: false, @@ -852,7 +852,7 @@ describe('IpnsService', () => { mockFolderIpnsRepo.create.mockImplementation((data) => ({ ...data, id: 'new-id', - sequenceNumber: '0', + sequenceNumber: '1', })); mockFolderIpnsRepo.save.mockImplementation((entity) => Promise.resolve({ ...entity })); diff --git a/apps/api/src/ipns/ipns.service.ts b/apps/api/src/ipns/ipns.service.ts index 137d6132d9..d9b601033e 100644 --- a/apps/api/src/ipns/ipns.service.ts +++ b/apps/api/src/ipns/ipns.service.ts @@ -224,12 +224,13 @@ export class IpnsService { return saved; } - // Create new entry + // Create new entry — sequence starts at '1' to match the IPNS record + // the client signed (clients compute newSeq = 0n + 1n = 1n for first publish). const folder = this.folderIpnsRepository.create({ userId, ipnsName, latestCid: metadataCid, - sequenceNumber: '0', + sequenceNumber: '1', encryptedIpnsPrivateKey: encryptedIpnsPrivateKey ? Buffer.from(encryptedIpnsPrivateKey, 'hex') : null, diff --git a/apps/api/src/migrations/1740500000000-BackfillIpnsSequenceNumbers.ts b/apps/api/src/migrations/1740500000000-BackfillIpnsSequenceNumbers.ts new file mode 100644 index 0000000000..16b44bf4bd --- /dev/null +++ b/apps/api/src/migrations/1740500000000-BackfillIpnsSequenceNumbers.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: Backfill IPNS sequence numbers from '0' to '1' + * + * Before this fix, upsertFolderIpns created new entries with + * sequenceNumber='0' while the client stored sequenceNumber=1n + * (computing 0+1=1 after the first publish). Phase 16 added + * conflict detection via expectedSequenceNumber, exposing this + * off-by-one mismatch as 409 errors. + * + * This migration bumps non-root entries that have published content + * (latestCid IS NOT NULL) from '0' to '1' to align with what + * the client already stored. + * + * Root entries (created during vault init with latestCid=null) are + * excluded because their first actual publish goes through the + * UPDATE path which correctly increments. + */ +export class BackfillIpnsSequenceNumbers1740500000000 implements MigrationInterface { + name = 'BackfillIpnsSequenceNumbers1740500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "folder_ipns" + SET "sequence_number" = '1' + WHERE "sequence_number" = '0' + AND "latest_cid" IS NOT NULL + AND "is_root" = false + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Not reversible — we can't distinguish which rows were backfilled + // vs. which legitimately had sequence_number='1' before. + // This is a data-only migration with no schema changes. + } +}