feat(journal): federation schema + per-user signing keypairs

social-federation tasks 2.1–2.5:

- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
  keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
  FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
  inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
  provenance + audience tagging for rows ingested from remote outboxes.
  Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
  factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
  RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
  when FEDERATION_ENABLED=true; only touches users with NULL keys, so
  re-runs are no-ops. New users get keys at registration (both passkey
  and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
  remote-ingested rows have no local owner; decide in task 7.2.

Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-06 14:02:57 +02:00
parent 4ef86e4dc2
commit 9a90b6db55
9 changed files with 322 additions and 34 deletions

View file

@ -44,6 +44,14 @@ export const users = journalSchema.table("users", {
// content defaults; existing users were backfilled to `public` by an
// earlier migration so behavior didn't change for them.
profileVisibility: text("profile_visibility").$type<ProfileVisibility>().notNull().default("private"),
// Federation signing keypair (spec: social-federation). The public key
// is a JWK JSON string embedded in the actor object; the private key is
// a JWK encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY (see
// apps/journal app/lib/federation-keys.server.ts). NULL until generated —
// at registration for new users, or by the one-shot
// `backfill-user-keypairs` job for users predating federation.
publicKey: text("public_key"),
privateKeyEncrypted: text("private_key_encrypted"),
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
termsVersion: text("terms_version"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@ -123,6 +131,16 @@ export const routeVersions = journalSchema.table("route_versions", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
/**
* Audience of an activity cached from a remote actor's outbox (spec:
* social-federation, "Audience-aware feed filtering"). Local activities
* are always 'public' here their actual visibility lives in
* `visibility`; this column only matters for remote rows, where
* 'followers-only' content must reach only the local viewer whose
* accepted follow brought it in.
*/
export type Audience = "public" | "followers-only";
export const activities = journalSchema.table("activities", {
id: text("id").primaryKey(),
ownerId: text("owner_id")
@ -142,6 +160,14 @@ export const activities = journalSchema.table("activities", {
participants: jsonb("participants").$type<string[]>(),
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
synthetic: boolean("synthetic").notNull().default(false),
// Federation provenance (spec: social-federation). NULL for local
// activities. For rows ingested from a remote trails actor's outbox:
// `remoteOriginIri` is the activity's IRI on the origin instance
// (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion), and
// `remoteActorIri` keys into `remote_actors`.
remoteOriginIri: text("remote_origin_iri").unique(),
remoteActorIri: text("remote_actor_iri"),
audience: text("audience").$type<Audience>().notNull().default("public"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
// Hot paths: listActivities (sort by started_at) and listPublicActivitiesForOwner.
@ -318,6 +344,26 @@ export const follows = journalSchema.table("follows", {
followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId),
}));
// Cache of remote ActivityPub actors we interact with (spec:
// social-federation). One row per actor IRI: display fields for feed
// cards, inbox/outbox URLs for delivery and polling, the public key for
// inbound HTTP-Signature verification, and `software` (the discovery
// field) for the trails-to-trails outbound check. Refreshed during
// outbox polls so feed renders never re-fetch the actor document.
export const remoteActors = journalSchema.table("remote_actors", {
actorIri: text("actor_iri").primaryKey(),
displayName: text("display_name"),
username: text("username"),
domain: text("domain"),
avatarUrl: text("avatar_url"),
inboxUrl: text("inbox_url"),
outboxUrl: text("outbox_url"),
publicKey: text("public_key"),
software: text("software"),
lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// Notifications. Each row is a single event the recipient should be
// informed about. v1 types: follow_request_received, follow_request_approved,
// follow_received, activity_published. The `payload` JSONB snapshots the