Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/provider-platform",
"version": "0.9.3",
"version": "0.9.4",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
95 changes: 95 additions & 0 deletions src/core/service/council-notify/handle-removal.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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, []);
});
96 changes: 96 additions & 0 deletions src/core/service/council-notify/handle-removal.ts
Original file line number Diff line number Diff line change
@@ -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<MembershipStatus> {
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<Array<{ publicKey: string }>> };
membershipRepo: {
getCurrentForPp(
ppPublicKey: string,
): Promise<CouncilMembership | undefined>;
update(
id: string,
fields: { status: CouncilMembershipStatus },
): Promise<unknown>;
};
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 };
}
46 changes: 40 additions & 6 deletions src/http/v1/council/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
144 changes: 144 additions & 0 deletions tests/integration/http/council-removed.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
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<Response> {
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<string> {
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);
});
Loading