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:
parent
4ef86e4dc2
commit
9a90b6db55
9 changed files with 322 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue