From 544b99631bc759a31c3ec7bf7e56d1ac0a73a31b Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 23 Jun 2026 10:21:26 -0300 Subject: [PATCH] feat(council): accept inbound PP-removal notice and converge Wire the empty council router stub into a real inbound endpoint: POST /api/v1/council/removed, the live signal a council sends when it removes this provider. The notice is low-trust: it is not believed on its own. For each PP with an ACTIVE membership in the named council we re-query the council's authoritative /public/provider/membership-status endpoint and demote to REJECTED only when the council confirms NOT_FOUND. A forged or stale notice, or a transient council error, can never knock a still-valid membership offline. The PP's own on-chain event-watcher stays the can't-miss path; this just reacts immediately instead of on next poll. - New env-free service core/service/council-notify/handle-removal.ts (unit-testable convergence logic with injectable council query). - Integration test drives the HTTP route through PGlite: 404 -> REJECTED, 200 -> untouched, missing councilId -> 400. Bumps 0.9.3 -> 0.9.4. --- deno.json | 2 +- .../council-notify/handle-removal.test.ts | 95 ++++++++++++ .../service/council-notify/handle-removal.ts | 96 ++++++++++++ src/http/v1/council/routes.ts | 46 +++++- .../integration/http/council-removed.test.ts | 144 ++++++++++++++++++ 5 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 src/core/service/council-notify/handle-removal.test.ts create mode 100644 src/core/service/council-notify/handle-removal.ts create mode 100644 tests/integration/http/council-removed.test.ts diff --git a/deno.json b/deno.json index 73ec56b..38538de 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/provider-platform", - "version": "0.9.3", + "version": "0.9.4", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/core/service/council-notify/handle-removal.test.ts b/src/core/service/council-notify/handle-removal.test.ts new file mode 100644 index 0000000..15bc6c1 --- /dev/null +++ b/src/core/service/council-notify/handle-removal.test.ts @@ -0,0 +1,95 @@ +import { assertEquals } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; +import { + handleCouncilRemovalNotice, + type MembershipStatus, + type RemovalNoticeDeps, +} from "./handle-removal.ts"; +import { + type CouncilMembership, + CouncilMembershipStatus, +} from "@/persistence/drizzle/entity/council-membership.entity.ts"; + +const CHANNEL_AUTH = "CCHANNELAUTH"; +const PP_PK = "GPP"; +const COUNCIL_URL = "https://council.example.com"; + +function membership(over: Partial): CouncilMembership { + return { + id: "m-1", + councilUrl: COUNCIL_URL, + councilName: "Council", + councilPublicKey: "GCOUNCIL", + channelAuthId: CHANNEL_AUTH, + status: CouncilMembershipStatus.ACTIVE, + configJson: null, + claimedJurisdictions: null, + joinRequestId: null, + ppPublicKey: PP_PK, + ...over, + } as CouncilMembership; +} + +function deps( + current: CouncilMembership | undefined, + councilSays: MembershipStatus, +) { + const updates: Array<{ id: string; status: CouncilMembershipStatus }> = []; + const d: RemovalNoticeDeps = { + ppRepo: { listAll: () => Promise.resolve([{ publicKey: PP_PK }]) }, + membershipRepo: { + getCurrentForPp: (_pk: string) => Promise.resolve(current), + update: (id: string, fields: { status: CouncilMembershipStatus }) => { + updates.push({ id, status: fields.status }); + return Promise.resolve(undefined); + }, + }, + log: newNoop(), + fetchStatus: (_url, _cid, _pk) => Promise.resolve(councilSays), + }; + return { d, updates }; +} + +Deno.test("council confirms NOT_FOUND → active membership demoted to REJECTED", async () => { + const { d, updates } = deps(membership({}), "NOT_FOUND"); + const res = await handleCouncilRemovalNotice(CHANNEL_AUTH, d); + assertEquals(res.deactivated, [PP_PK]); + assertEquals(updates, [{ + id: "m-1", + status: CouncilMembershipStatus.REJECTED, + }]); +}); + +Deno.test("council still reports ACTIVE → forged/stale notice is ignored", async () => { + const { d, updates } = deps(membership({}), "ACTIVE"); + const res = await handleCouncilRemovalNotice(CHANNEL_AUTH, d); + assertEquals(res.deactivated, []); + assertEquals(updates, []); +}); + +Deno.test("council unreachable (UNKNOWN) → membership left untouched", async () => { + const { d, updates } = deps(membership({}), "UNKNOWN"); + const res = await handleCouncilRemovalNotice(CHANNEL_AUTH, d); + assertEquals(res.deactivated, []); + assertEquals(updates, []); +}); + +Deno.test("notice for a different council → skipped", async () => { + const { d, updates } = deps( + membership({ channelAuthId: "COTHER" }), + "NOT_FOUND", + ); + const res = await handleCouncilRemovalNotice(CHANNEL_AUTH, d); + assertEquals(res.deactivated, []); + assertEquals(updates, []); +}); + +Deno.test("membership not currently ACTIVE → skipped (idempotent)", async () => { + const { d, updates } = deps( + membership({ status: CouncilMembershipStatus.REJECTED }), + "NOT_FOUND", + ); + const res = await handleCouncilRemovalNotice(CHANNEL_AUTH, d); + assertEquals(res.deactivated, []); + assertEquals(updates, []); +}); diff --git a/src/core/service/council-notify/handle-removal.ts b/src/core/service/council-notify/handle-removal.ts new file mode 100644 index 0000000..93ff5a7 --- /dev/null +++ b/src/core/service/council-notify/handle-removal.ts @@ -0,0 +1,96 @@ +import type { Logger } from "@/utils/logger/index.ts"; +import { + type CouncilMembership, + CouncilMembershipStatus, +} from "@/persistence/drizzle/entity/council-membership.entity.ts"; + +/** Authoritative membership verdict from the council's public endpoint. */ +export type MembershipStatus = "ACTIVE" | "PENDING" | "NOT_FOUND" | "UNKNOWN"; + +/** + * Ask a council whether `publicKey` is still an active provider. Mirrors the + * council's `GET /api/v1/public/provider/membership-status` contract: + * 200 → ACTIVE, 202 → PENDING, 404 → NOT_FOUND, anything else / error → UNKNOWN. + * Best-effort: never throws so a flaky council can't wedge the notice handler + * (the watcher remains the can't-miss path). + */ +export async function fetchMembershipStatus( + councilUrl: string, + channelAuthId: string, + publicKey: string, +): Promise { + try { + const base = councilUrl.replace(/\/+$/, ""); + const res = await fetch( + `${base}/api/v1/public/provider/membership-status?councilId=${ + encodeURIComponent(channelAuthId) + }&publicKey=${encodeURIComponent(publicKey)}`, + ); + if (res.status === 200) return "ACTIVE"; + if (res.status === 202) return "PENDING"; + if (res.status === 404) return "NOT_FOUND"; + return "UNKNOWN"; + } catch { + return "UNKNOWN"; + } +} + +export interface RemovalNoticeDeps { + ppRepo: { listAll(): Promise> }; + membershipRepo: { + getCurrentForPp( + ppPublicKey: string, + ): Promise; + update( + id: string, + fields: { status: CouncilMembershipStatus }, + ): Promise; + }; + log: Logger; + /** Injectable for tests; defaults to the real council query. */ + fetchStatus?: typeof fetchMembershipStatus; +} + +/** + * Handle a council "you were removed" notice for `channelAuthId`. + * + * The notice is NOT trusted on its own: for every PP that currently holds an + * ACTIVE membership in that council, we re-query the council's authoritative + * membership-status endpoint and demote to REJECTED only when the council + * confirms NOT_FOUND. ACTIVE / PENDING / UNKNOWN are left untouched, so a forged + * or stale notice — or a transient council error — can never knock a still-valid + * membership offline. The PP's own on-chain event-watcher remains the + * can't-miss path; this just reacts immediately instead of on the next poll. + * + * Returns the public keys that were demoted. + */ +export async function handleCouncilRemovalNotice( + channelAuthId: string, + deps: RemovalNoticeDeps, +): Promise<{ deactivated: string[] }> { + const fetchStatus = deps.fetchStatus ?? fetchMembershipStatus; + const log = deps.log.scope("councilRemovalNotice"); + const deactivated: string[] = []; + + const pps = await deps.ppRepo.listAll(); + for (const pp of pps) { + const membership = await deps.membershipRepo.getCurrentForPp(pp.publicKey); + if (!membership || membership.channelAuthId !== channelAuthId) continue; + if (membership.status !== CouncilMembershipStatus.ACTIVE) continue; + + const status = await fetchStatus( + membership.councilUrl, + channelAuthId, + pp.publicKey, + ); + if (status === "NOT_FOUND") { + await deps.membershipRepo.update(membership.id, { + status: CouncilMembershipStatus.REJECTED, + }); + deactivated.push(pp.publicKey); + log.event("PP membership deactivated via council removal notice"); + } + } + + return { deactivated }; +} diff --git a/src/http/v1/council/routes.ts b/src/http/v1/council/routes.ts index 1ba4ea9..def6abe 100644 --- a/src/http/v1/council/routes.ts +++ b/src/http/v1/council/routes.ts @@ -1,10 +1,44 @@ -import { Router } from "@oak/oak"; +import { type Context, Router } from "@oak/oak"; import type { Logger } from "@/utils/logger/index.ts"; +import { drizzleClient } from "@/persistence/drizzle/config.ts"; +import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts"; +import { CouncilMembershipRepository } from "@/persistence/drizzle/repository/council-membership.repository.ts"; +import { handleCouncilRemovalNotice } from "@/core/service/council-notify/handle-removal.ts"; -// Callback endpoints (config-push, status-update) removed. -// PP now determines its own state via on-chain queries and -// the council's public membership-status endpoint. +// PP determines its own state via on-chain queries and the council's public +// membership-status endpoint. The one inbound endpoint here is a low-trust live +// signal from a council that a provider was removed — it triggers an immediate +// re-query+converge rather than being believed on its own. -export function buildCouncilRouter(_deps: { log: Logger }): Router { - return new Router(); +export function buildCouncilRouter(deps: { log: Logger }): Router { + const router = new Router(); + const log = deps.log.scope("council"); + + // POST /api/v1/council/removed — "you were removed" notice from a council. + // We re-query the council's authoritative membership-status endpoint and only + // demote memberships the council confirms are gone. The on-chain event-watcher + // remains the can't-miss path; this just reacts faster. + router.post("/council/removed", async (ctx: Context) => { + const body = await ctx.request.body.json().catch(() => null); + const channelAuthId = body?.councilId ?? body?.channelAuthId; + if (typeof channelAuthId !== "string" || channelAuthId.length === 0) { + ctx.response.status = 400; + ctx.response.body = { message: "councilId is required" }; + return; + } + + const result = await handleCouncilRemovalNotice(channelAuthId, { + ppRepo: new PpRepository(drizzleClient), + membershipRepo: new CouncilMembershipRepository(drizzleClient), + log, + }); + + ctx.response.status = 202; + ctx.response.body = { + status: "accepted", + deactivated: result.deactivated.length, + }; + }); + + return router; } diff --git a/tests/integration/http/council-removed.test.ts b/tests/integration/http/council-removed.test.ts new file mode 100644 index 0000000..6988a99 --- /dev/null +++ b/tests/integration/http/council-removed.test.ts @@ -0,0 +1,144 @@ +// deno-lint-ignore-file no-explicit-any +import "../../ensure_test_env.ts"; +import { Application, Router } from "@oak/oak"; +import { assertEquals } from "@std/assert"; +import { eq } from "drizzle-orm"; +import { ensureInitialized, getTestDb, resetDb } from "../../test_helpers.ts"; +import { newNoop } from "@/utils/logger/index.ts"; +import { paymentProvider } from "@/persistence/drizzle/entity/pp.entity.ts"; +import { + councilMembership, + CouncilMembershipStatus, +} from "@/persistence/drizzle/entity/council-membership.entity.ts"; + +const REMOVED_PATH = "http://localhost/api/v1/council/removed"; +const CHANNEL_AUTH = "CCHANNELAUTH"; +const PP_PK = "GPROVIDERPUBLICKEY"; +const COUNCIL_URL = "http://council.test"; + +// Imported after PGlite is wired up (tests/deno.json remaps the db config). +const { buildCouncilRouter } = await import("@/http/v1/council/routes.ts"); + +function createTestApp(): Application { + const app = new Application(); + const router = new Router(); + const councilRouter = buildCouncilRouter({ log: newNoop() }); + router.use( + "/api/v1", + councilRouter.routes(), + councilRouter.allowedMethods(), + ); + app.use(router.routes()); + app.use(router.allowedMethods()); + return app; +} + +async function seedActiveMembership() { + const db = getTestDb(); + // resetDb() doesn't clear these tables; clear them here for isolation. + await db.delete(councilMembership); + await db.delete(paymentProvider); + await db.insert(paymentProvider).values({ + id: "pp-1", + publicKey: PP_PK, + encryptedSk: "enc", + derivationIndex: 0, + isActive: true, + }); + await db.insert(councilMembership).values({ + id: "m-1", + councilUrl: COUNCIL_URL, + councilPublicKey: "GCOUNCIL", + channelAuthId: CHANNEL_AUTH, + status: CouncilMembershipStatus.ACTIVE, + ppPublicKey: PP_PK, + }); +} + +/** Run `fn` with global fetch replaced by a stub returning `httpStatus`. */ +async function withCouncilStatus( + httpStatus: number, + fn: () => Promise, +): Promise { + const orig = globalThis.fetch; + globalThis.fetch = ((_input: any) => + Promise.resolve( + new Response(JSON.stringify({ status: "stub" }), { status: httpStatus }), + )) as typeof fetch; + try { + await fn(); + } finally { + globalThis.fetch = orig; + } +} + +async function postRemoved(app: Application, body: unknown): Promise { + const res = await app.handle( + new Request(REMOVED_PATH, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + if (!res) throw new Error("No response from Oak app"); + return res; +} + +async function membershipStatus(): Promise { + const db = getTestDb(); + const [row] = await db + .select() + .from(councilMembership) + .where(eq(councilMembership.id, "m-1")); + return row.status; +} + +Deno.test({ + name: "council-removed suite setup", + async fn() { + await ensureInitialized(); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test("400 when councilId is missing", async () => { + await ensureInitialized(); + await resetDb(); + const app = createTestApp(); + const res = await postRemoved(app, {}); + assertEquals(res.status, 400); + await res.body?.cancel(); +}); + +Deno.test("council confirms removal (404) → membership demoted to REJECTED", async () => { + await ensureInitialized(); + await resetDb(); + await seedActiveMembership(); + const app = createTestApp(); + + await withCouncilStatus(404, async () => { + const res = await postRemoved(app, { councilId: CHANNEL_AUTH }); + assertEquals(res.status, 202); + const body = await res.json(); + assertEquals(body.deactivated, 1); + }); + + assertEquals(await membershipStatus(), CouncilMembershipStatus.REJECTED); +}); + +Deno.test("council still reports ACTIVE (200) → membership untouched", async () => { + await ensureInitialized(); + await resetDb(); + await seedActiveMembership(); + const app = createTestApp(); + + await withCouncilStatus(200, async () => { + const res = await postRemoved(app, { councilId: CHANNEL_AUTH }); + assertEquals(res.status, 202); + const body = await res.json(); + assertEquals(body.deactivated, 0); + }); + + assertEquals(await membershipStatus(), CouncilMembershipStatus.ACTIVE); +});