From 17c7088d702220a28df0c576cc517ea1dea326bb Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 3 Mar 2026 18:29:13 +0100 Subject: [PATCH 1/2] fix(api): start new IPNS entries at seq 1 to match client expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 16 added conflict detection via expectedSequenceNumber, but upsertFolderIpns created new entries with sequenceNumber='0' while the client computes newSeq = 0+1 = 1 and stores that. On the next publish the client sends expectedSeq='1', server has '0' → 409. The retry also fails because resolveRecord prefers the network IPNS record (which the client signed with seq=1) over the DB value (seq=0), so resync returns the same stale value and the retry gets 409 again. Fix: initialize new IPNS entries at seq='1' to match the IPNS record the client actually signed for first publish. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 6374ba359bf4 --- apps/api/src/ipns/ipns.service.spec.ts | 20 ++++++++++---------- apps/api/src/ipns/ipns.service.ts | 5 +++-- 2 files changed, 13 insertions(+), 12 deletions(-) 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, From 98b9e326559b65a41b6721bb5a693272395df355 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 3 Mar 2026 19:06:32 +0100 Subject: [PATCH 2/2] fix(api): add migration to backfill pre-existing seq=0 IPNS entries Entries created before the seq='1' fix have seq='0' in the DB but the client stored seq=1n. This migration bumps non-root entries that have published content (latestCid IS NOT NULL) from '0' to '1'. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: b5da120786f5 --- ...40500000000-BackfillIpnsSequenceNumbers.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/api/src/migrations/1740500000000-BackfillIpnsSequenceNumbers.ts 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. + } +}