Skip to content
Open
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
6 changes: 3 additions & 3 deletions backend/src/db/resolvesRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,11 @@ export const resolvesRepo = {
return Boolean(data);
},

async addWitness(targetSosId: string, witness: ResolveWitness, receiptId: string): Promise<boolean> {
async addWitness(targetSosId: string, witness: ResolveWitness): Promise<boolean> {
if (!isSupabaseConfigured || !supabase) {
return resolveStore.recordWitness(targetSosId, witness);
}
const row = {
receipt_id: receiptId,
target_sos_id: targetSosId,
device_id: witness.deviceId,
pubkey: witness.pubkey,
Expand Down Expand Up @@ -92,7 +91,7 @@ export const resolvesRepo = {
};
const { error } = await supabase
.from(RECEIPTS_TABLE)
.upsert(row, { onConflict: "id", ignoreDuplicates: false });
.upsert(row, { onConflict: "target_sos_id", ignoreDuplicates: false });
if (error) throw error;
},

Expand Down Expand Up @@ -121,6 +120,7 @@ export const resolvesRepo = {
.select("target_sos_id")
.eq("target_sos_author", authorDeviceId)
.eq("status", "pending")
.not("cooldown_ends_at", "is", null)
.neq("target_sos_id", excludeTargetId)
.order("created_at", { ascending: false })
.limit(1)
Expand Down
3 changes: 1 addition & 2 deletions backend/src/resolve/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,8 @@ export async function resolveRoutes(fastify: FastifyInstance) {
}

// Persist each new witness.
const provisionalId = getResolveId(envelope);
for (const w of newAccepted) {
await resolvesRepo.addWitness(envelope.targetSosId, w, provisionalId);
await resolvesRepo.addWitness(envelope.targetSosId, w);
}

const allWitnesses = await resolvesRepo.getWitnesses(envelope.targetSosId);
Expand Down
77 changes: 77 additions & 0 deletions backend/src/resolve/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, test, expect, beforeEach } from "bun:test";
import type { ResolveReceipt } from "@guacamaya/shared";
import { resolveStore } from "./store.js";

function makeReceipt(overrides: Partial<ResolveReceipt> = {}): ResolveReceipt {
return {
id: `receipt-${Math.random().toString(36).slice(2)}`,
targetSosId: "a".repeat(64),
targetSosAuthor: "device-" + "11".repeat(32),
status: "pending",
quorumNeeded: 2,
quorumSeen: 1,
witnessDeviceIds: ["device-" + "11".repeat(32)],
createdAt: Date.now(),
...overrides,
};
}

describe("resolveStore", () => {
beforeEach(() => {
resolveStore.clear();
});

test("upsertReceipt does NOT register pending-clear while accumulating witnesses", () => {
resolveStore.upsertReceipt(makeReceipt({ quorumSeen: 1, cooldownEndsAt: undefined }));
expect(resolveStore.getPendingClear("a".repeat(64))).toBeUndefined();
});

test("upsertReceipt registers pending-clear when cooldown starts", () => {
const cooldownEndsAt = Date.now() + 15 * 60_000;
resolveStore.upsertReceipt(makeReceipt({ quorumSeen: 2, cooldownEndsAt }));
const pc = resolveStore.getPendingClear("a".repeat(64));
expect(pc).toBeDefined();
expect(pc!.cooldownEndsAt).toBe(cooldownEndsAt);
expect(pc!.disputed).toBe(false);
});

test("promoteExpiredClears promotes expired cooldown to cleared", () => {
const now = Date.now();
const target = "b".repeat(64);
resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, cooldownEndsAt: now - 1000 }));
const promoted = resolveStore.promoteExpiredClears(now);
expect(promoted.length).toBe(1);
expect(promoted[0].status).toBe("cleared");
expect(resolveStore.getPendingClear(target)).toBeUndefined();
});

test("autoDispute marks pending-clear and prevents promotion", () => {
const now = Date.now();
const target = "c".repeat(64);
resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, cooldownEndsAt: now - 1000 }));
const disputed = resolveStore.autoDispute(target, "originator_refire");
expect(disputed!.status).toBe("disputed");
expect(resolveStore.getPendingClear(target)!.disputed).toBe(true);
expect(resolveStore.promoteExpiredClears(now).length).toBe(0);
});

test("findPendingClearForAuthor only finds receipts in real cooldown", () => {
const author = "device-" + "dd".repeat(32);
const accumulating = "d".repeat(64);
const cooldown = "e".repeat(64);
resolveStore.upsertReceipt(makeReceipt({ targetSosId: accumulating, targetSosAuthor: author, cooldownEndsAt: undefined }));
resolveStore.upsertReceipt(makeReceipt({ targetSosId: cooldown, targetSosAuthor: author, cooldownEndsAt: Date.now() + 60_000 }));
expect(resolveStore.findPendingClearForAuthor(author, "x".repeat(64))).toBe(cooldown);
});

test("upserting same target replaces previous receipt", () => {
const target = "ff".repeat(32);
resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, quorumSeen: 1, cooldownEndsAt: undefined }));
expect(resolveStore.getReceiptByTarget(target)!.quorumSeen).toBe(1);
expect(resolveStore.getPendingClear(target)).toBeUndefined();

resolveStore.upsertReceipt(makeReceipt({ targetSosId: target, quorumSeen: 2, cooldownEndsAt: Date.now() + 15 * 60_000 }));
expect(resolveStore.getReceiptByTarget(target)!.quorumSeen).toBe(2);
expect(resolveStore.getPendingClear(target)).toBeDefined();
});
});
5 changes: 5 additions & 0 deletions backend/src/resolve/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const resolveStore = {
upsertReceipt(receipt: ResolveReceipt): void {
receiptsByTarget.set(receipt.targetSosId, receipt);
receiptsById.set(receipt.id, receipt);
if (receipt.status === "pending" && typeof receipt.cooldownEndsAt === "number") {
this.markPendingClear(receipt.targetSosId, receipt.id, receipt.cooldownEndsAt);
} else {
pendingClears.delete(receipt.targetSosId);
}
},

getReceiptById(id: string): ResolveReceipt | undefined {
Expand Down
19 changes: 19 additions & 0 deletions backend/supabase/migrations/0001_fix_resolve_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
begin;

alter table if exists public.resolve_witnesses
drop constraint if exists resolve_witnesses_receipt_id_fkey;

alter table if exists public.resolve_witnesses
drop column if exists receipt_id;

with ranked as (
select id, row_number() over (partition by target_sos_id order by created_at desc, id desc) as rn
from public.resolve_receipts
)
delete from public.resolve_receipts
where id in (select id from ranked where rn > 1);

alter table if exists public.resolve_receipts
add constraint resolve_receipts_target_sos_id_unique unique (target_sos_id);

commit;
5 changes: 2 additions & 3 deletions backend/supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ alter table public.location_points enable row level security;

create table if not exists public.resolve_receipts (
id text primary key, -- sha256(canonical + sorted witness deviceIds)
target_sos_id text not null, -- channel_records.id of the original SOS
target_sos_id text not null unique, -- channel_records.id of the original SOS (one receipt per target)
target_sos_author text not null, -- "device-<pubkey-hex>" of the originator
status text not null, -- "pending" | "cleared" | "disputed" | "rejected"
quorum_needed integer not null,
Expand All @@ -83,8 +83,7 @@ create index if not exists resolve_receipts_author_pending_idx
alter table public.resolve_receipts enable row level security;

create table if not exists public.resolve_witnesses (
receipt_id text not null references public.resolve_receipts(id) on delete cascade,
target_sos_id text not null, -- denormalized for the unique constraint + lookup
target_sos_id text not null, -- lookup key
device_id text not null, -- "device-<pubkey-hex>"
pubkey text not null, -- 32-byte Ed25519 hex
lat double precision not null,
Expand Down