From 68f4722e32d0596c73a4a74c4f2f20a0b62c5714 Mon Sep 17 00:00:00 2001 From: hannahhoward Date: Mon, 18 May 2026 19:59:03 -0700 Subject: [PATCH] feat: clear extended provider via ClearExtendedProvider special message Adds a new SQS special-message body 'ClearExtendedProvider' that, when received, publishes an advertisement whose ExtendedProvider.Providers field is explicitly empty. storetheindex >= e1afa6b8 (PR #2814) honors this shape via its registry.Update Empty() branch, setting info.ExtendedProviders = nil so subsequent lookups return no ExtendedProvider entries for the publisher peer ID. See ipni/storetheindex#1745 for the design rationale. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/handlers/advertisement.js | 20 ++++++++++ test/advertisement.test.js | 69 ++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/handlers/advertisement.js b/src/handlers/advertisement.js index de42f38..73c05c8 100644 --- a/src/handlers/advertisement.js +++ b/src/handlers/advertisement.js @@ -18,6 +18,9 @@ const telemetry = require('../telemetry') // see: https://github.com/elastic-ipfs/publisher-lambda/pull/27 const SPECIAL_MESSAGE_ANNOUNCE_HTTP_PROVIDER = 'AnnounceHTTP' +// see: https://github.com/ipni/storetheindex/pull/2814 +const SPECIAL_MESSAGE_CLEAR_EXTENDED_PROVIDER = 'ClearExtendedProvider' + async function fetchHeadCid() { try { telemetry.increaseCount('http-head-cid-fetchs') @@ -147,6 +150,7 @@ async function main(event) { for (const record of event.Records) { let ad + let clearExtendedProvider = false if (record.body === SPECIAL_MESSAGE_ANNOUNCE_HTTP_PROVIDER) { const http = new Provider({ @@ -158,6 +162,14 @@ async function main(event) { previous: headCid, providers: [bits, http] }) + } else if (record.body === SPECIAL_MESSAGE_CLEAR_EXTENDED_PROVIDER) { + ad = new Advertisement({ + previous: headCid, + providers: [bits], + entries: null, + context: null + }) + clearExtendedProvider = true } else { const entries = CID.parse(record.body) const context = Buffer.from(entries.toString()) @@ -170,6 +182,14 @@ async function main(event) { } const value = await ad.encodeAndSign() + // The library only writes ExtendedProvider when providers.length > 1; attach + // the empty-Providers shape after signing so storetheindex's Empty() clearing + // path triggers (https://github.com/ipni/storetheindex/pull/2814). The AD + // signature covers previous+entries+provider+addresses+metadata+IsRm only, + // so this attachment does not invalidate it. + if (clearExtendedProvider) { + value.ExtendedProvider = { Providers: [], Override: false } + } const block = await Block.encode({ value, codec: dagJson, hasher: sha256 }) // Upload the file to S3 diff --git a/test/advertisement.test.js b/test/advertisement.test.js index 39c480a..f681a83 100644 --- a/test/advertisement.test.js +++ b/test/advertisement.test.js @@ -6,7 +6,7 @@ const t = require('tap') const varint = require('varint') const dagJson = require('@ipld/dag-json') const { MockAgent, setGlobalDispatcher } = require('undici') -const { awsRegion, s3Bucket, indexerNodeUrl } = require('../src/config') +const { awsRegion, s3Bucket, indexerNodeUrl, bitswapPeerMultiaddr } = require('../src/config') const { handler } = require('../src/index') const { trackAWSUsages, mockPeerIds } = require('./utils/mock') @@ -291,3 +291,70 @@ t.test('advertisement - handles indexer announcing generic error', async t => { { message: 'ERROR' } ) }) + +t.test('advertisement - clear extended provider', async t => { + t.plan(18) + + const mockAgent = new MockAgent() + const mockHeadPool = mockAgent.get(`https://${s3Bucket}.s3.${awsRegion}.amazonaws.com`) + const mockIndexerPool = mockAgent.get(indexerNodeUrl) + mockPeerIds() + + const head = 'baguqeeralr4pwxvbcc6voioqyc6aneg4pkoh5rhrfj35gbhrpxpeavsh6vsa' + mockAgent.disableNetConnect() + mockHeadPool + .intercept({ method: 'GET', path: '/head' }) + .reply(200, `{"head": {"/": "${head}"}}`, { + headers: { 'content-type': 'application/json' } + }) + mockIndexerPool + .intercept({ + method: 'PUT', + path: '/ingest/announce' + }) + .reply(204, '') + + trackAWSUsages(t) + setGlobalDispatcher(mockAgent) + + t.strictSame( + await handler({ + Records: [ + { body: 'ClearExtendedProvider' } + ] + }), + {} + ) + + t.equal(t.context.s3.puts.length, 2) + t.equal(t.context.s3.puts[0].Bucket, s3Bucket) + t.ok(t.context.s3.puts[0].Key.startsWith('bagu')) + t.equal(t.context.s3.puts[1].Bucket, s3Bucket) + t.equal(t.context.s3.puts[1].Key, 'head') + + const ad = dagJson.decode(t.context.s3.puts[0].Body) + + // AC6 chain continuity + t.equal(ad.PreviousID.toString(), head) + + // AC3 ExtendedProvider field present with empty Providers array + t.ok(ad.ExtendedProvider) + t.ok(Array.isArray(ad.ExtendedProvider.Providers)) + t.equal(ad.ExtendedProvider.Providers.length, 0) + + // AC4 Override field present (value not locked) + t.ok('Override' in ad.ExtendedProvider) + + // AC5 base provider unchanged - Addresses + t.ok(Array.isArray(ad.Addresses)) + t.equal(ad.Addresses.length, 1) + t.equal(ad.Addresses[0], bitswapPeerMultiaddr) + + // AC5 base provider unchanged - Provider peer ID + t.equal(typeof ad.Provider, 'string') + t.ok(ad.Provider.length > 0) + + // AC7 signature present + t.ok(ad.Signature instanceof Uint8Array || Buffer.isBuffer(ad.Signature)) + t.ok(ad.Signature.length > 0) +})