Skip to content

Commit 6958979

Browse files
authored
feat: add denylist CRUD endpoints with moderator authorization (#114)
* feat: add denylist table, migrations, and CRUD endpoints with moderator authorization - Migration 1745846400000_denylist.ts: new denylist table with entity_id (PK), reason, created_by, created_at, updated_at audit columns and case-insensitive indexes - Extends IDbComponent with getDenylist / addDenylistEntry / removeDenylistEntry - Extends IRefreshableFeaturesComponent with getUserModerators() polling dapps-platform_user_moderators feature flag (same pattern as malicious-profiles) - GET /denylist: public endpoint returning all blocked entity IDs - POST /denylist/:entityId: signed-fetch + moderator-only, upserts an entry - DELETE /denylist/:entityId: signed-fetch + moderator-only, removes an entry - Unit and integration tests covering all paths including 403 for non-moderators * fix: address PR feedback on denylist feature - Wrap long line in refreshable-features/component.ts (prettier) - Preserve original created_by on upsert conflict - Remove redundant LOWER(entity_id) index (data already stored lowercase) * docs: add denylist endpoints to OpenAPI spec * feat: filter denylisted entities from POST /entities/active (#115) * feat: filter denylisted entities from POST /entities/active Mirrors the catalyst behavior by excluding denylisted entities at the SQL level using a NOT EXISTS subquery against the denylist table, avoiding a separate round-trip to fetch the full denylist. * fix: drop redundant LOWER on denylist side and fix flaky ordering test - Use denylist.entity_id = LOWER(registries.id) instead of comparing both sides with LOWER(), since addDenylistEntry already stores entity_id in lowercase — allows PostgreSQL to use the PK index on entity_id - Make "should return multiple entities" order-independent with arrayContaining since getSortedRegistriesByPointers has no guaranteed ordering when sortOrder is not set
1 parent 0e7bb92 commit 6958979

19 files changed

Lines changed: 848 additions & 5 deletions

docs/openapi.yaml

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ tags:
5555
description: Processing queue status and monitoring
5656
- name: Admin
5757
description: Administrative endpoints (requires bearer token authentication)
58+
- name: Denylist
59+
description: Denylist management endpoints — block specific entity IDs from being served
5860
paths:
5961
/status:
6062
get:
@@ -374,6 +376,129 @@ paths:
374376
application/json:
375377
schema:
376378
$ref: '#/components/schemas/Error'
379+
/denylist:
380+
get:
381+
tags:
382+
- Denylist
383+
summary: Get all denylist entries
384+
description: |
385+
Returns all entity IDs currently in the denylist. This endpoint is public and requires no authentication.
386+
operationId: getDenylist
387+
security: []
388+
responses:
389+
'200':
390+
description: List of denylist entries
391+
content:
392+
application/json:
393+
schema:
394+
type: array
395+
items:
396+
$ref: '#/components/schemas/DenylistEntry'
397+
/denylist/{entityId}:
398+
post:
399+
tags:
400+
- Denylist
401+
summary: Add or update a denylist entry
402+
description: |
403+
Adds an entity ID to the denylist or updates an existing entry. Requires a valid signed fetch
404+
from an address listed in the `dapps-platform_user_moderators` feature flag.
405+
406+
If the entity is already in the denylist, its `reason` and `updated_at` fields are updated
407+
but `created_by` is preserved from the original entry.
408+
operationId: addDenylistEntry
409+
security:
410+
- SignedFetch: []
411+
parameters:
412+
- name: entityId
413+
in: path
414+
required: true
415+
schema:
416+
type: string
417+
description: Entity ID (e.g. a Catalyst CID) to add to the denylist
418+
requestBody:
419+
required: false
420+
content:
421+
application/json:
422+
schema:
423+
type: object
424+
properties:
425+
reason:
426+
type: string
427+
nullable: true
428+
description: Optional human-readable reason for denylisting this entity
429+
responses:
430+
'201':
431+
description: Entry created or updated
432+
content:
433+
application/json:
434+
schema:
435+
$ref: '#/components/schemas/DenylistEntry'
436+
'400':
437+
description: Bad request — entity ID is missing
438+
content:
439+
application/json:
440+
schema:
441+
$ref: '#/components/schemas/OperationResult'
442+
'401':
443+
description: Unauthorized — invalid or missing signed fetch
444+
content:
445+
application/json:
446+
schema:
447+
$ref: '#/components/schemas/Error'
448+
'403':
449+
description: Forbidden — signer is not an authorized moderator
450+
content:
451+
application/json:
452+
schema:
453+
$ref: '#/components/schemas/OperationResult'
454+
delete:
455+
tags:
456+
- Denylist
457+
summary: Remove a denylist entry
458+
description: |
459+
Removes an entity ID from the denylist. Requires a valid signed fetch from an address
460+
listed in the `dapps-platform_user_moderators` feature flag.
461+
operationId: removeDenylistEntry
462+
security:
463+
- SignedFetch: []
464+
parameters:
465+
- name: entityId
466+
in: path
467+
required: true
468+
schema:
469+
type: string
470+
description: Entity ID to remove from the denylist
471+
responses:
472+
'200':
473+
description: Entry successfully removed
474+
content:
475+
application/json:
476+
schema:
477+
$ref: '#/components/schemas/OperationResult'
478+
'400':
479+
description: Bad request — entity ID is missing
480+
content:
481+
application/json:
482+
schema:
483+
$ref: '#/components/schemas/OperationResult'
484+
'401':
485+
description: Unauthorized — invalid or missing signed fetch
486+
content:
487+
application/json:
488+
schema:
489+
$ref: '#/components/schemas/Error'
490+
'403':
491+
description: Forbidden — signer is not an authorized moderator
492+
content:
493+
application/json:
494+
schema:
495+
$ref: '#/components/schemas/OperationResult'
496+
'404':
497+
description: Entity ID not found in the denylist
498+
content:
499+
application/json:
500+
schema:
501+
$ref: '#/components/schemas/OperationResult'
377502
/flush-cache:
378503
delete:
379504
tags:
@@ -796,6 +921,43 @@ components:
796921
required:
797922
- ok
798923
- message
924+
DenylistEntry:
925+
type: object
926+
description: A single entry in the denylist, representing a blocked entity
927+
properties:
928+
entity_id:
929+
type: string
930+
description: Lowercase entity ID (Catalyst CID) that is blocked
931+
reason:
932+
type: string
933+
nullable: true
934+
description: Optional human-readable reason why this entity was denylisted
935+
created_by:
936+
type: string
937+
description: Lowercase Ethereum address of the moderator who first added the entry
938+
created_at:
939+
type: number
940+
description: Unix timestamp in milliseconds when the entry was created
941+
updated_at:
942+
type: number
943+
description: Unix timestamp in milliseconds when the entry was last updated
944+
required:
945+
- entity_id
946+
- created_by
947+
- created_at
948+
- updated_at
949+
OperationResult:
950+
type: object
951+
description: Generic result for mutating operations
952+
properties:
953+
ok:
954+
type: boolean
955+
description: Whether the operation succeeded
956+
message:
957+
type: string
958+
description: Optional human-readable message
959+
required:
960+
- ok
799961
Error:
800962
type: object
801963
properties:

src/adapters/db.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import SQL, { SQLStatement } from 'sql-template-strings'
22
import {
33
AppComponents,
4+
Denylist,
45
IDbComponent,
56
Registry,
67
SetSpawnCoordinateResult,
@@ -61,7 +62,7 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
6162
pointers: string[],
6263
options?: GetSortedRegistriesByPointersOptions
6364
): Promise<Registry.DbEntity[]> {
64-
const { statuses, sortOrder, worldName } = options ?? {}
65+
const { statuses, sortOrder, worldName, excludeDenylisted } = options ?? {}
6566
const order = sortOrder === SortOrder.DESC ? 'DESC' : 'ASC'
6667
const lowerCasePointers = pointers.map((p) => p.toLowerCase())
6768

@@ -100,6 +101,14 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
100101
`)
101102
}
102103

104+
if (excludeDenylisted) {
105+
query.append(SQL`
106+
AND NOT EXISTS (
107+
SELECT 1 FROM denylist WHERE denylist.entity_id = LOWER(registries.id)
108+
)
109+
`)
110+
}
111+
103112
if (sortOrder) {
104113
query.append(`ORDER BY timestamp ${order}`)
105114
}
@@ -1434,6 +1443,43 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
14341443
})
14351444
}
14361445

1446+
async function getDenylist(): Promise<Denylist.DbEntity[]> {
1447+
const query: SQLStatement = SQL`
1448+
SELECT entity_id, reason, created_by, created_at, updated_at
1449+
FROM denylist
1450+
ORDER BY created_at DESC
1451+
`
1452+
const result = await pg.query<Denylist.DbEntity>(query)
1453+
return result.rows
1454+
}
1455+
1456+
async function addDenylistEntry(
1457+
entityId: string,
1458+
createdBy: string,
1459+
reason?: string | null
1460+
): Promise<Denylist.DbEntity> {
1461+
const now = Date.now()
1462+
const query: SQLStatement = SQL`
1463+
INSERT INTO denylist (entity_id, reason, created_by, created_at, updated_at)
1464+
VALUES (${entityId.toLowerCase()}, ${reason ?? null}, ${createdBy.toLowerCase()}, ${now}, ${now})
1465+
ON CONFLICT (entity_id) DO UPDATE
1466+
SET reason = EXCLUDED.reason,
1467+
updated_at = EXCLUDED.updated_at
1468+
RETURNING entity_id, reason, created_by, created_at, updated_at
1469+
`
1470+
const result = await pg.query<Denylist.DbEntity>(query)
1471+
return result.rows[0]
1472+
}
1473+
1474+
async function removeDenylistEntry(entityId: string): Promise<boolean> {
1475+
const query: SQLStatement = SQL`
1476+
DELETE FROM denylist
1477+
WHERE LOWER(entity_id) = ${entityId.toLowerCase()}
1478+
`
1479+
const result = await pg.query(query)
1480+
return (result.rowCount ?? 0) > 0
1481+
}
1482+
14371483
return {
14381484
insertRegistry,
14391485
updateRegistriesStatus,
@@ -1471,6 +1517,9 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
14711517
recalculateSpawnCoordinate,
14721518
undeployWorldScenes,
14731519
undeployWorldByName,
1474-
persistRegistryInTransaction
1520+
persistRegistryInTransaction,
1521+
getDenylist,
1522+
addDenylistEntry,
1523+
removeDenylistEntry
14751524
}
14761525
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { DecentralandSignatureContext } from '@dcl/platform-crypto-middleware'
2+
import { HandlerContextWithPath } from '../../types'
3+
4+
export async function deleteDenylistEntryHandler(
5+
context: HandlerContextWithPath<'db' | 'refreshableFeatures', '/denylist/:entityId'> &
6+
DecentralandSignatureContext<any>
7+
) {
8+
const {
9+
components: { db, refreshableFeatures },
10+
params,
11+
verification
12+
} = context
13+
14+
const { entityId } = params
15+
if (!entityId) {
16+
return {
17+
status: 400,
18+
body: { ok: false, message: 'Entity ID is required' }
19+
}
20+
}
21+
22+
const signerAddress = verification!.auth.toLowerCase()
23+
const moderators = await refreshableFeatures.getUserModerators()
24+
25+
if (!moderators || !moderators.includes(signerAddress)) {
26+
return {
27+
status: 403,
28+
body: { ok: false, message: 'Forbidden: signer is not an authorized moderator' }
29+
}
30+
}
31+
32+
const deleted = await db.removeDenylistEntry(entityId)
33+
if (!deleted) {
34+
return {
35+
status: 404,
36+
body: { ok: false, message: 'Entity ID not found in denylist' }
37+
}
38+
}
39+
40+
return {
41+
status: 200,
42+
body: { ok: true }
43+
}
44+
}

src/controllers/handlers/get-active-entities.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export async function getActiveEntityHandler(context: HandlerContextWithPath<'db
2727

2828
const entities = await db.getSortedRegistriesByPointers(pointers, {
2929
statuses: [Registry.Status.COMPLETE, Registry.Status.FALLBACK],
30-
worldName
30+
worldName,
31+
excludeDenylisted: true
3132
})
3233

3334
if (entities.length === 0) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { HandlerContextWithPath } from '../../types'
2+
3+
export async function getDenylistHandler(context: HandlerContextWithPath<'db', '/denylist'>) {
4+
const {
5+
components: { db }
6+
} = context
7+
8+
const entries = await db.getDenylist()
9+
10+
return {
11+
body: entries
12+
}
13+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { DecentralandSignatureContext } from '@dcl/platform-crypto-middleware'
2+
import { HandlerContextWithPath } from '../../types'
3+
4+
export async function postDenylistEntryHandler(
5+
context: HandlerContextWithPath<'db' | 'refreshableFeatures', '/denylist/:entityId'> &
6+
DecentralandSignatureContext<any>
7+
) {
8+
const {
9+
components: { db, refreshableFeatures },
10+
params,
11+
verification
12+
} = context
13+
14+
const { entityId } = params
15+
if (!entityId) {
16+
return {
17+
status: 400,
18+
body: { ok: false, message: 'Entity ID is required' }
19+
}
20+
}
21+
22+
const signerAddress = verification!.auth.toLowerCase()
23+
const moderators = await refreshableFeatures.getUserModerators()
24+
25+
if (!moderators || !moderators.includes(signerAddress)) {
26+
return {
27+
status: 403,
28+
body: { ok: false, message: 'Forbidden: signer is not an authorized moderator' }
29+
}
30+
}
31+
32+
const body = await context.request.json().catch(() => ({}))
33+
const reason: string | null = body?.reason ?? null
34+
35+
const entry = await db.addDenylistEntry(entityId, signerAddress, reason)
36+
37+
return {
38+
status: 201,
39+
body: entry
40+
}
41+
}

0 commit comments

Comments
 (0)