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>
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
// Postgres-backed Fedify KvStore. Fedify uses its KvStore for inbox
|
|
// replay protection (activity-id idempotence), remote document caching,
|
|
// and key caching — all state that must survive process restarts and be
|
|
// shared across instances of the journal server, which rules out
|
|
// MemoryKvStore outside the dev loop (spec: social-federation 4.7).
|
|
//
|
|
// Keys are KvKey (readonly string[]) serialized as their JSON encoding;
|
|
// prefix listing relies on JSON arrays sharing a stable textual prefix
|
|
// up to the closing bracket.
|
|
|
|
import type { KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions } from "@fedify/fedify";
|
|
import { eq, sql, like, and, or, isNull, gt, lt } from "drizzle-orm";
|
|
import { federationKv } from "@trails-cool/db/schema/journal";
|
|
import { getDb } from "./db.ts";
|
|
|
|
function serializeKey(key: KvKey): string {
|
|
return JSON.stringify(key);
|
|
}
|
|
|
|
/** Predicate: row is not expired (expires_at NULL or in the future). */
|
|
function notExpired() {
|
|
return or(isNull(federationKv.expiresAt), gt(federationKv.expiresAt, new Date()));
|
|
}
|
|
|
|
export class PostgresKvStore implements KvStore {
|
|
async get<T = unknown>(key: KvKey): Promise<T | undefined> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select({ value: federationKv.value })
|
|
.from(federationKv)
|
|
.where(and(eq(federationKv.key, serializeKey(key)), notExpired()))
|
|
.limit(1);
|
|
return row === undefined ? undefined : (row.value as T);
|
|
}
|
|
|
|
async set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise<void> {
|
|
const db = getDb();
|
|
const expiresAt = options?.ttl
|
|
? new Date(Date.now() + options.ttl.total("milliseconds"))
|
|
: null;
|
|
await db
|
|
.insert(federationKv)
|
|
.values({ key: serializeKey(key), value, expiresAt })
|
|
.onConflictDoUpdate({
|
|
target: federationKv.key,
|
|
set: { value, expiresAt },
|
|
});
|
|
}
|
|
|
|
async delete(key: KvKey): Promise<void> {
|
|
const db = getDb();
|
|
await db.delete(federationKv).where(eq(federationKv.key, serializeKey(key)));
|
|
}
|
|
|
|
async *list(prefix?: KvKey): AsyncIterable<KvStoreListEntry> {
|
|
const db = getDb();
|
|
const wherePrefix = prefix
|
|
? // '["a","b"]' minus the trailing ']' is a textual prefix of every
|
|
// key extending ["a","b"] — and of nothing else, since the next
|
|
// character is either ']' (exact) or ',' (extension).
|
|
like(federationKv.key, `${serializeKey(prefix).slice(0, -1)}%`)
|
|
: undefined;
|
|
const rows = await db
|
|
.select({ key: federationKv.key, value: federationKv.value })
|
|
.from(federationKv)
|
|
.where(wherePrefix ? and(wherePrefix, notExpired()) : notExpired());
|
|
for (const row of rows) {
|
|
yield { key: JSON.parse(row.key) as KvKey, value: row.value };
|
|
}
|
|
}
|
|
|
|
/** Remove expired rows. Called by the federation-kv-sweep job. */
|
|
async sweepExpired(): Promise<number> {
|
|
const db = getDb();
|
|
const deleted = await db
|
|
.delete(federationKv)
|
|
.where(and(sql`${federationKv.expiresAt} IS NOT NULL`, lt(federationKv.expiresAt, new Date())))
|
|
.returning({ key: federationKv.key });
|
|
return deleted.length;
|
|
}
|
|
}
|