diff --git a/src/routes/disputes/index.test.ts b/src/routes/disputes/index.test.ts new file mode 100644 index 0000000..8f23931 --- /dev/null +++ b/src/routes/disputes/index.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import Fastify from "fastify"; +import { disputeRoutes } from "./index.js"; +import { DisputeStore } from "./store.js"; + +async function buildApp(opts: { windowMs?: number; store?: DisputeStore } = {}) { + const app = Fastify(); + await app.register(disputeRoutes, opts); + await app.ready(); + return app; +} + +function headers(role: string, userId: string) { + return { "x-role": role, "x-user-id": userId, "content-type": "application/json" }; +} + +test("a buyer can raise a dispute with evidence, hash-committed", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "POST", + url: "/disputes", + headers: headers("buyer", "buyer-1"), + payload: { + settlementId: "s1", + reason: "item not delivered", + buyerId: "buyer-1", + sellerId: "seller-1", + evidence: "tracking shows no delivery", + }, + }); + + assert.equal(res.statusCode, 201); + const body = res.json(); + assert.equal(body.status, "open"); + assert.equal(body.evidence.length, 1); + assert.equal(body.evidence[0].contentHash.length, 64); // sha256 hex + assert.equal(body.evidence[0].content, "tracking shows no delivery"); +}); + +test("listing is role-scoped: a seller cannot see another party's dispute", async () => { + const app = await buildApp(); + await app.inject({ + method: "POST", + url: "/disputes", + headers: headers("buyer", "buyer-1"), + payload: { + settlementId: "s1", + reason: "x", + buyerId: "buyer-1", + sellerId: "seller-1", + }, + }); + + const asOutsider = await app.inject({ + method: "GET", + url: "/disputes", + headers: headers("seller", "seller-unrelated"), + }); + assert.deepEqual(asOutsider.json(), []); + + const asAdjudicator = await app.inject({ + method: "GET", + url: "/disputes", + headers: headers("adjudicator", "adj-1"), + }); + assert.equal(asAdjudicator.json().length, 1); +}); + +test("only a party to the dispute may add evidence", async () => { + const app = await buildApp(); + const raise = await app.inject({ + method: "POST", + url: "/disputes", + headers: headers("buyer", "buyer-1"), + payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" }, + }); + const id = raise.json().id; + + const outsider = await app.inject({ + method: "POST", + url: `/disputes/${id}/evidence`, + headers: headers("buyer", "someone-else"), + payload: { content: "not my dispute" }, + }); + assert.equal(outsider.statusCode, 403); + + const seller = await app.inject({ + method: "POST", + url: `/disputes/${id}/evidence`, + headers: headers("seller", "seller-1"), + payload: { content: "here's my side" }, + }); + assert.equal(seller.statusCode, 200); + assert.equal(seller.json().evidence.length, 1); +}); + +test("only an adjudicator may resolve, and resolving fires a notification", async () => { + const store = new DisputeStore(); + const app = await buildApp({ store }); + const raise = await app.inject({ + method: "POST", + url: "/disputes", + headers: headers("buyer", "buyer-1"), + payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" }, + }); + const id = raise.json().id; + + const asBuyer = await app.inject({ + method: "POST", + url: `/disputes/${id}/resolve`, + headers: headers("buyer", "buyer-1"), + payload: { outcome: "buyer", notes: "resolved" }, + }); + assert.equal(asBuyer.statusCode, 403); + + const asAdjudicator = await app.inject({ + method: "POST", + url: `/disputes/${id}/resolve`, + headers: headers("adjudicator", "adj-1"), + payload: { outcome: "buyer", notes: "evidence supports buyer" }, + }); + assert.equal(asAdjudicator.statusCode, 200); + assert.equal(asAdjudicator.json().status, "resolved"); + + const events = store.notifications.map((n) => n.event); + assert.deepEqual(events, ["raised", "resolved"]); +}); + +test("evidence is rejected once the window has closed", async () => { + const app = await buildApp({ windowMs: -1 }); // already closed + const raise = await app.inject({ + method: "POST", + url: "/disputes", + headers: headers("buyer", "buyer-1"), + payload: { settlementId: "s1", reason: "x", buyerId: "buyer-1", sellerId: "seller-1" }, + }); + const id = raise.json().id; + + const res = await app.inject({ + method: "POST", + url: `/disputes/${id}/evidence`, + headers: headers("buyer", "buyer-1"), + payload: { content: "too late" }, + }); + assert.equal(res.statusCode, 409); +}); + +test("requests without a recognized role/user are rejected", async () => { + const app = await buildApp(); + const res = await app.inject({ method: "GET", url: "/disputes" }); + assert.equal(res.statusCode, 401); +}); diff --git a/src/routes/disputes/index.ts b/src/routes/disputes/index.ts new file mode 100644 index 0000000..a6bf2db --- /dev/null +++ b/src/routes/disputes/index.ts @@ -0,0 +1,100 @@ +import type { FastifyPluginAsync, FastifyRequest } from "fastify"; +import { DisputeStore } from "./store.js"; +import { + addEvidenceSchema, + disputeRoleSchema, + raiseDisputeSchema, + resolveDisputeSchema, + type DisputeRole, +} from "./types.js"; + +export interface DisputeRoutesOptions { + /** How long the evidence window stays open after a dispute is raised. */ + windowMs?: number; + store?: DisputeStore; +} + +/** + * Everything the dispute lifecycle needs: raise, list (role-scoped), + * attach evidence, and resolve. Auth is a placeholder — the repo has no + * session/identity layer yet, so role and user identity are read from + * headers the same way a real gateway would inject them post-auth. This + * keeps the role-scoping logic itself (the part the acceptance criteria + * care about) real and testable without inventing an auth system. + */ +export const disputeRoutes: FastifyPluginAsync = async ( + app, + opts, +) => { + const store = opts.store ?? new DisputeStore(); + const windowMs = opts.windowMs ?? 7 * 24 * 60 * 60 * 1000; // 7 days + + function actor(req: FastifyRequest): { role: DisputeRole; userId: string } | null { + const roleHeader = req.headers["x-role"]; + const userId = req.headers["x-user-id"]; + const parsed = disputeRoleSchema.safeParse( + Array.isArray(roleHeader) ? roleHeader[0] : roleHeader, + ); + if (!parsed.success || typeof userId !== "string" || userId.length === 0) return null; + return { role: parsed.data, userId }; + } + + app.post("/disputes", async (req, reply) => { + const who = actor(req); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + + const body = raiseDisputeSchema.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); + + const dispute = store.raise({ + ...body.data, + raisedBy: who.role, + windowMs, + }); + return reply.code(201).send(dispute); + }); + + app.get("/disputes", async (req, reply) => { + const who = actor(req); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + + return reply.send(store.listForRole(who.role, who.userId)); + }); + + app.post<{ Params: { id: string } }>("/disputes/:id/evidence", async (req, reply) => { + const who = actor(req); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + + const dispute = store.get(req.params.id); + if (!dispute) return reply.code(404).send({ error: "dispute not found" }); + if (!store.canAccess(dispute, who.role, who.userId)) { + return reply.code(403).send({ error: "not a party to this dispute" }); + } + if (!store.isWindowOpen(dispute)) { + return reply.code(409).send({ error: "evidence window has closed" }); + } + + const body = addEvidenceSchema.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); + + const updated = store.addEvidence(req.params.id, who.role, body.data.content); + return reply.send(updated); + }); + + app.post<{ Params: { id: string } }>("/disputes/:id/resolve", async (req, reply) => { + const who = actor(req); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + if (who.role !== "adjudicator") { + return reply.code(403).send({ error: "only an adjudicator may resolve a dispute" }); + } + + const dispute = store.get(req.params.id); + if (!dispute) return reply.code(404).send({ error: "dispute not found" }); + + const body = resolveDisputeSchema.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); + + const updated = store.resolve(req.params.id, body.data.outcome, body.data.notes); + return reply.send(updated); + }); +}; diff --git a/src/routes/disputes/store.ts b/src/routes/disputes/store.ts new file mode 100644 index 0000000..87cc409 --- /dev/null +++ b/src/routes/disputes/store.ts @@ -0,0 +1,118 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { Dispute, DisputeRole, EvidenceItem, Notification } from "./types.js"; + +export function hashEvidence(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +export interface RaiseDisputeInput { + settlementId: string; + reason: string; + buyerId: string; + sellerId: string; + raisedBy: DisputeRole; + evidence?: string; + windowMs: number; + now?: number; +} + +/** + * In-memory store for the dispute lifecycle, with role-scoped queries and a + * notification log. Evidence content is kept alongside its hash here; a + * production deployment would put `content` in access-controlled off-chain + * storage and commit only `contentHash` on-chain (same principle as + * inference payloads) — this store already keeps that split so wiring in + * real off-chain storage and a real on-chain commit call is a swap of one + * function, not a data-model change. + */ +export class DisputeStore { + private disputes = new Map(); + notifications: Notification[] = []; + + constructor(private readonly now: () => number = Date.now) {} + + raise(input: RaiseDisputeInput): Dispute { + const createdAt = input.now ?? this.now(); + const evidence: EvidenceItem[] = input.evidence + ? [ + { + id: randomUUID(), + submittedBy: input.raisedBy, + content: input.evidence, + contentHash: hashEvidence(input.evidence), + createdAt, + }, + ] + : []; + + const dispute: Dispute = { + id: randomUUID(), + settlementId: input.settlementId, + reason: input.reason, + status: "open", + buyerId: input.buyerId, + sellerId: input.sellerId, + evidence, + windowClosesAt: createdAt + input.windowMs, + createdAt, + updatedAt: createdAt, + }; + this.disputes.set(dispute.id, dispute); + this.notify(dispute.id, "raised"); + return dispute; + } + + get(id: string): Dispute | undefined { + return this.disputes.get(id); + } + + /** Role-scoped: buyers/sellers only see disputes they're a party to. */ + listForRole(role: DisputeRole, userId: string): Dispute[] { + const all = [...this.disputes.values()]; + if (role === "adjudicator") return all; + return all.filter((d) => d.buyerId === userId || d.sellerId === userId); + } + + canAccess(dispute: Dispute, role: DisputeRole, userId: string): boolean { + if (role === "adjudicator") return true; + return dispute.buyerId === userId || dispute.sellerId === userId; + } + + isWindowOpen(dispute: Dispute): boolean { + return this.now() < dispute.windowClosesAt; + } + + addEvidence(id: string, submittedBy: DisputeRole, content: string): Dispute | undefined { + const dispute = this.disputes.get(id); + if (!dispute) return undefined; + dispute.evidence.push({ + id: randomUUID(), + submittedBy, + content, + contentHash: hashEvidence(content), + createdAt: this.now(), + }); + dispute.status = "evidence"; + dispute.updatedAt = this.now(); + this.notify(id, "evidence-added"); + return dispute; + } + + resolve( + id: string, + outcome: "buyer" | "seller" | "split", + notes: string, + ): Dispute | undefined { + const dispute = this.disputes.get(id); + if (!dispute) return undefined; + dispute.status = "resolved"; + dispute.outcome = { outcome, notes, resolvedAt: this.now() }; + dispute.updatedAt = this.now(); + this.notify(id, "resolved"); + return dispute; + } + + private notify(disputeId: string, event: Notification["event"]) { + this.notifications.push({ disputeId, event, at: this.now() }); + } +} diff --git a/src/routes/disputes/types.ts b/src/routes/disputes/types.ts new file mode 100644 index 0000000..bab972e --- /dev/null +++ b/src/routes/disputes/types.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const disputeRoleSchema = z.enum(["buyer", "seller", "adjudicator"]); +export type DisputeRole = z.infer; + +export const disputeStatusSchema = z.enum(["open", "evidence", "resolved"]); +export type DisputeStatus = z.infer; + +export const raiseDisputeSchema = z.object({ + settlementId: z.string().min(1), + reason: z.string().min(1), + // The settlement service isn't wired up yet, so the two parties are + // supplied by the caller rather than looked up by settlementId. + buyerId: z.string().min(1), + sellerId: z.string().min(1), + evidence: z.string().min(1).optional(), +}); + +export const addEvidenceSchema = z.object({ + content: z.string().min(1), +}); + +export const resolveDisputeSchema = z.object({ + outcome: z.enum(["buyer", "seller", "split"]), + notes: z.string().min(1), +}); + +export interface EvidenceItem { + id: string; + submittedBy: DisputeRole; + /** sha256 commitment of `content` — this is what would go on-chain. */ + contentHash: string; + content: string; + createdAt: number; +} + +export interface Dispute { + id: string; + settlementId: string; + reason: string; + status: DisputeStatus; + buyerId: string; + sellerId: string; + evidence: EvidenceItem[]; + outcome?: { outcome: "buyer" | "seller" | "split"; notes: string; resolvedAt: number }; + windowClosesAt: number; + createdAt: number; + updatedAt: number; +} + +export interface Notification { + disputeId: string; + event: "raised" | "evidence-added" | "resolved" | "window-expired"; + at: number; +} diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index e283fad..045c4fd 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsync } from "fastify"; +import { disputeRoutes } from "../disputes/index.js"; export const v1Routes: FastifyPluginAsync = async (app) => { app.get("/meta", async () => ({ @@ -7,5 +8,7 @@ export const v1Routes: FastifyPluginAsync = async (app) => { description: "REST facade for Soroban contracts and indexers (scaffold).", })); + await app.register(disputeRoutes); + // TODO: routes for contract invocation prep, webhook ingestion, admin ops };