social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user can follow a public trails user: WebFinger → actor fetch → signed Follow → recorded + Accept(Follow) pushed back. Identity surface (section 3): - Actor objects now carry the user's public key (publicKey + assertionMethods via Fedify key pairs dispatcher; keys generated lazily as a fallback to the backfill) and an inbox IRI; url uses localActorIri (3.1). - Software discovery shipped as standard NodeInfo (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool) instead of the originally-sketched custom AS actor field — Fedify's typed vocab can't emit arbitrary actor props and NodeInfo is what the fediverse reads. Artifacts updated accordingly (3.4). Inbox (section 4): - /users/:username/inbox resource route; HTTP Signatures verified by Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per source instance (host from Signature keyId) BEFORE verification so hostile instances can't burn CPU on key fetches (4.8). - Listeners: Follow → auto-accept for public profiles + Accept pushed back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) → Pending settled + first outbox poll enqueued (4.4); Reject(Follow) → Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types are acknowledged + dropped by Fedify (4.6). - Replay protection via Fedify's KvStore, now Postgres-backed (journal.federation_kv + daily sweep job) so dedupe survives restarts (4.7). Schema (discovered requirement): - follows.follower_id relaxed to nullable + follows.follower_actor_iri for inbound remote followers — the proposal's 'follows is already federation-ready' only held for outbound. Check constraint enforces exactly one follower identity; partial unique index dedupes remote follows. Notification fan-out + approve flow now filter local followers explicitly. Design/proposal updated. Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/ settle/reject/check-constraint), 6 KvStore integration tests, 2 unit tests for source-host extraction. All against real Postgres, gated on FEDERATION_INTEGRATION=1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65 lines
2.4 KiB
TypeScript
65 lines
2.4 KiB
TypeScript
import { describe, it, expect, afterAll } from "vitest";
|
|
import { like } from "drizzle-orm";
|
|
import { getDb } from "./db.ts";
|
|
import { federationKv } from "@trails-cool/db/schema/journal";
|
|
import { PostgresKvStore } from "./federation-kv.server.ts";
|
|
|
|
// Opt-in: talks to real Postgres (same convention as the other
|
|
// *.integration.test.ts files).
|
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
|
|
|
// Namespace keys per test run so concurrent runs can't collide.
|
|
const NS = `test-${Date.now()}`;
|
|
|
|
describe.runIf(runIntegration)("PostgresKvStore (integration)", () => {
|
|
const store = new PostgresKvStore();
|
|
|
|
afterAll(async () => {
|
|
const db = getDb();
|
|
await db.delete(federationKv).where(like(federationKv.key, `["${NS}"%`));
|
|
});
|
|
|
|
it("roundtrips JSON values", async () => {
|
|
await store.set([NS, "a"], { hello: "world", n: 42 });
|
|
expect(await store.get([NS, "a"])).toEqual({ hello: "world", n: 42 });
|
|
});
|
|
|
|
it("returns undefined for missing keys", async () => {
|
|
expect(await store.get([NS, "missing"])).toBeUndefined();
|
|
});
|
|
|
|
it("overwrites on set (upsert)", async () => {
|
|
await store.set([NS, "b"], 1);
|
|
await store.set([NS, "b"], 2);
|
|
expect(await store.get([NS, "b"])).toBe(2);
|
|
});
|
|
|
|
it("expired values read as missing and are swept", async () => {
|
|
// Duck-typed Temporal.Duration — the store only calls
|
|
// .total("milliseconds"), and pulling in the polyfill just for the
|
|
// test isn't worth a dependency.
|
|
const oneMs = { total: () => 1 } as unknown as Temporal.Duration;
|
|
await store.set([NS, "ttl"], "ephemeral", { ttl: oneMs });
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(await store.get([NS, "ttl"])).toBeUndefined();
|
|
const purged = await store.sweepExpired();
|
|
expect(purged).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("deletes keys", async () => {
|
|
await store.set([NS, "c"], "x");
|
|
await store.delete([NS, "c"]);
|
|
expect(await store.get([NS, "c"])).toBeUndefined();
|
|
});
|
|
|
|
it("lists by prefix without matching sibling prefixes", async () => {
|
|
await store.set([NS, "list", "one"], 1);
|
|
await store.set([NS, "list", "two"], 2);
|
|
await store.set([NS, "listish"], 3); // shares textual prefix, different key path
|
|
|
|
const entries = [];
|
|
for await (const e of store.list([NS, "list"])) entries.push(e);
|
|
const keys = entries.map((e) => e.key.join("/")).sort();
|
|
expect(keys).toEqual([`${NS}/list/one`, `${NS}/list/two`]);
|
|
});
|
|
});
|