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
26
apps/journal/app/jobs/backfill-user-keypairs.ts
Normal file
26
apps/journal/app/jobs/backfill-user-keypairs.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* One-shot backfill: generate a federation keypair for every user who
|
||||
* predates federation (spec: "Existing-user backfill at deploy"). The
|
||||
* server enqueues this once at startup whenever FEDERATION_ENABLED is
|
||||
* on (singleton-keyed, so repeat startups don't stack runs); each run
|
||||
* only touches users whose public_key IS NULL, so re-runs are no-ops.
|
||||
* New users get keys at registration and never appear in this workload.
|
||||
*/
|
||||
export const backfillUserKeypairsJob: JobDefinition = {
|
||||
name: "backfill-user-keypairs",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
async handler() {
|
||||
const ids = await listUsersWithoutKeypair();
|
||||
let generated = 0;
|
||||
for (const id of ids) {
|
||||
if (await ensureUserKeypair(id)) generated++;
|
||||
}
|
||||
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
|
||||
return { candidates: ids.length, generated };
|
||||
},
|
||||
};
|
||||
|
|
@ -13,6 +13,9 @@ import type {
|
|||
} from "@simplewebauthn/types";
|
||||
import { getDb } from "./db.ts";
|
||||
import { getOrigin } from "./config.server.ts";
|
||||
import { federationEnabled } from "./federation.server.ts";
|
||||
import { ensureUserKeypair } from "./federation-keys.server.ts";
|
||||
import { logger } from "./logger.server.ts";
|
||||
import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
@ -92,9 +95,27 @@ export async function finishRegistration(
|
|||
transports: response.response.transports,
|
||||
});
|
||||
|
||||
await generateFederationKeysBestEffort(userId);
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec (social-federation): new users get federation signing keys at
|
||||
* registration. Best-effort — a keygen failure must never fail the
|
||||
* registration itself; the `backfill-user-keypairs` job is the safety
|
||||
* net for any user this skips. No-op while federation is off (the
|
||||
* backfill covers everyone when the flag first flips on).
|
||||
*/
|
||||
async function generateFederationKeysBestEffort(userId: string): Promise<void> {
|
||||
if (!federationEnabled()) return;
|
||||
try {
|
||||
await ensureUserKeypair(userId);
|
||||
} catch (err) {
|
||||
logger.warn({ err, userId }, "federation keypair generation failed at registration; backfill will retry");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add Passkey to Existing Account ---
|
||||
|
||||
export async function addPasskeyStart(userId: string) {
|
||||
|
|
@ -176,6 +197,8 @@ export async function registerWithMagicLink(
|
|||
termsVersion,
|
||||
});
|
||||
|
||||
await generateFederationKeysBestEffort(userId);
|
||||
|
||||
// Same shape as login's createMagicToken — token for the click-through
|
||||
// link, 6-digit code for paste-from-email/SMS flows (mobile).
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
|
|
|
|||
|
|
@ -1,41 +1,61 @@
|
|||
// AES-256-GCM encrypt/decrypt for storing sensitive integration credentials.
|
||||
// Key is derived from INTEGRATION_SECRET env var using scrypt.
|
||||
// AES-256-GCM encrypt/decrypt for storing sensitive credentials at rest.
|
||||
// Keys are derived from a secret env var using scrypt.
|
||||
//
|
||||
// Encoded format: base64(salt_hex:iv_hex:ciphertext_hex:authtag_hex)
|
||||
// Encoded format: base64(iv_hex:ciphertext_hex:authtag_hex)
|
||||
// All fields are hex-encoded before joining so the separator is unambiguous.
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
const SALT = "trails-cool-integrations";
|
||||
const KEY_LEN = 32; // AES-256
|
||||
const IV_LEN = 12; // GCM standard
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.INTEGRATION_SECRET;
|
||||
if (!secret) throw new Error("INTEGRATION_SECRET env var is not set");
|
||||
return scryptSync(secret, SALT, KEY_LEN) as Buffer;
|
||||
export interface AesCipher {
|
||||
encrypt(plaintext: string): string;
|
||||
decrypt(encoded: string): string;
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":");
|
||||
return Buffer.from(payload).toString("base64");
|
||||
/**
|
||||
* Build an encrypt/decrypt pair keyed from `secretEnvVar`. The key is
|
||||
* derived lazily on first use so importing this module never throws —
|
||||
* only actually encrypting/decrypting requires the env var to be set.
|
||||
* Distinct `salt` per use-case keeps derived keys independent even if
|
||||
* two env vars were ever set to the same value.
|
||||
*/
|
||||
export function createAesCipher(secretEnvVar: string, salt: string): AesCipher {
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env[secretEnvVar];
|
||||
if (!secret) throw new Error(`${secretEnvVar} env var is not set`);
|
||||
return scryptSync(secret, salt, KEY_LEN) as Buffer;
|
||||
}
|
||||
|
||||
return {
|
||||
encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":");
|
||||
return Buffer.from(payload).toString("base64");
|
||||
},
|
||||
decrypt(encoded: string): string {
|
||||
const key = getKey();
|
||||
const payload = Buffer.from(encoded, "base64").toString("utf8");
|
||||
const parts = payload.split(":");
|
||||
if (parts.length !== 3) throw new Error("Invalid encrypted payload format");
|
||||
const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string];
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const encrypted = Buffer.from(encryptedHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decrypt(encoded: string): string {
|
||||
const key = getKey();
|
||||
const payload = Buffer.from(encoded, "base64").toString("utf8");
|
||||
const parts = payload.split(":");
|
||||
if (parts.length !== 3) throw new Error("Invalid encrypted payload format");
|
||||
const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string];
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const encrypted = Buffer.from(encryptedHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
}
|
||||
// Integration-credential cipher (Komoot web-login, etc). Pre-existing
|
||||
// callers import { encrypt, decrypt } directly; keep that surface.
|
||||
const integrations = createAesCipher("INTEGRATION_SECRET", "trails-cool-integrations");
|
||||
export const encrypt = integrations.encrypt;
|
||||
export const decrypt = integrations.decrypt;
|
||||
|
|
|
|||
54
apps/journal/app/lib/federation-keys.server.test.ts
Normal file
54
apps/journal/app/lib/federation-keys.server.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.FEDERATION_KEY_ENCRYPTION_KEY = "test-federation-key-secret";
|
||||
});
|
||||
|
||||
const { generateUserKeypair, loadUserKeypair } = await import(
|
||||
"./federation-keys.server.ts"
|
||||
);
|
||||
|
||||
describe("federation keypairs", () => {
|
||||
it("generates a serializable RSA keypair with encrypted private key", async () => {
|
||||
const pair = await generateUserKeypair();
|
||||
|
||||
const publicJwk = JSON.parse(pair.publicKey);
|
||||
expect(publicJwk.kty).toBe("RSA");
|
||||
// 2048-bit modulus → 256 bytes → 342 base64url chars (spec wants RSA 2048)
|
||||
expect(publicJwk.n.length).toBeGreaterThanOrEqual(342);
|
||||
|
||||
// Private key must not be stored as plaintext JWK
|
||||
expect(pair.privateKeyEncrypted).not.toContain('"kty"');
|
||||
expect(() => JSON.parse(pair.privateKeyEncrypted)).toThrow();
|
||||
});
|
||||
|
||||
it("roundtrips through load into usable CryptoKeys that sign and verify", async () => {
|
||||
const stored = await generateUserKeypair();
|
||||
const loaded = await loadUserKeypair(stored);
|
||||
expect(loaded).not.toBeNull();
|
||||
|
||||
const data = new TextEncoder().encode("signed federation payload");
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
loaded!.privateKey,
|
||||
data,
|
||||
);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
loaded!.publicKey,
|
||||
signature,
|
||||
data,
|
||||
);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for users without stored keys", async () => {
|
||||
expect(await loadUserKeypair({ publicKey: null, privateKeyEncrypted: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("fails to load when the encryption key is wrong", async () => {
|
||||
const stored = await generateUserKeypair();
|
||||
process.env.FEDERATION_KEY_ENCRYPTION_KEY = "a-different-secret";
|
||||
await expect(loadUserKeypair(stored)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
98
apps/journal/app/lib/federation-keys.server.ts
Normal file
98
apps/journal/app/lib/federation-keys.server.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Per-user federation signing keypairs (spec: social-federation,
|
||||
// "Per-user signing keypairs").
|
||||
//
|
||||
// Every federating user has an RSA keypair: the public key is embedded
|
||||
// in their actor object for inbound signature verification by remotes;
|
||||
// the private key signs our outbound requests (HTTP Signatures). Keys
|
||||
// are stored as JWK JSON on journal.users — public in plaintext,
|
||||
// private encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY.
|
||||
//
|
||||
// RSASSA-PKCS1-v1_5 (RSA 2048) is the algorithm Mastodon and the wider
|
||||
// fediverse interoperate on for HTTP Signatures; Ed25519 support is
|
||||
// still patchy across implementations.
|
||||
//
|
||||
// Key-rotation runbook: rotating FEDERATION_KEY_ENCRYPTION_KEY requires
|
||||
// decrypt-with-old + encrypt-with-new over journal.users — documented in
|
||||
// docs/deployment.md (task 10.2).
|
||||
|
||||
import { generateCryptoKeyPair, exportJwk, importJwk } from "@fedify/fedify";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "./db.ts";
|
||||
import { createAesCipher } from "./crypto.server.ts";
|
||||
import { requireSecret } from "./config.server.ts";
|
||||
|
||||
const cipher = createAesCipher("FEDERATION_KEY_ENCRYPTION_KEY", "trails-cool-federation-keys");
|
||||
|
||||
/**
|
||||
* Fail fast in production when the encryption key is missing or the
|
||||
* known-public dev fallback. Called before any key material is written.
|
||||
*/
|
||||
function assertEncryptionKeyConfigured(): void {
|
||||
requireSecret("FEDERATION_KEY_ENCRYPTION_KEY", "dev-only-federation-key");
|
||||
// requireSecret throws in prod for unset/fallback values; in dev it
|
||||
// returns the fallback, which createAesCipher's env lookup won't see —
|
||||
// so mirror it into the env for the cipher when unset.
|
||||
process.env.FEDERATION_KEY_ENCRYPTION_KEY ??= "dev-only-federation-key";
|
||||
}
|
||||
|
||||
export interface SerializedKeypair {
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
}
|
||||
|
||||
/** Generate a fresh RSA 2048 keypair, serialized for journal.users. */
|
||||
export async function generateUserKeypair(): Promise<SerializedKeypair> {
|
||||
assertEncryptionKeyConfigured();
|
||||
const { publicKey, privateKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5");
|
||||
return {
|
||||
publicKey: JSON.stringify(await exportJwk(publicKey)),
|
||||
privateKeyEncrypted: cipher.encrypt(JSON.stringify(await exportJwk(privateKey))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store a keypair for `userId` if it doesn't have one.
|
||||
* Concurrency-safe: the UPDATE is guarded on `public_key IS NULL`, so
|
||||
* a racing generation loses and the first written pair stays canonical.
|
||||
* Returns true when this call generated the pair.
|
||||
*/
|
||||
export async function ensureUserKeypair(userId: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ publicKey: users.publicKey })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
if (!row || row.publicKey !== null) return false;
|
||||
const pair = await generateUserKeypair();
|
||||
const updated = await db
|
||||
.update(users)
|
||||
.set({ publicKey: pair.publicKey, privateKeyEncrypted: pair.privateKeyEncrypted })
|
||||
.where(and(eq(users.id, userId), isNull(users.publicKey)))
|
||||
.returning({ id: users.id });
|
||||
return updated.length > 0;
|
||||
}
|
||||
|
||||
/** Deserialize a user's stored keypair into CryptoKeys for Fedify. */
|
||||
export async function loadUserKeypair(row: {
|
||||
publicKey: string | null;
|
||||
privateKeyEncrypted: string | null;
|
||||
}): Promise<CryptoKeyPair | null> {
|
||||
if (!row.publicKey || !row.privateKeyEncrypted) return null;
|
||||
assertEncryptionKeyConfigured();
|
||||
return {
|
||||
publicKey: await importJwk(JSON.parse(row.publicKey), "public"),
|
||||
privateKey: await importJwk(JSON.parse(cipher.decrypt(row.privateKeyEncrypted)), "private"),
|
||||
};
|
||||
}
|
||||
|
||||
/** All user ids still lacking a keypair (backfill workload). */
|
||||
export async function listUsersWithoutKeypair(): Promise<string[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(isNull(users.publicKey));
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
|
@ -178,6 +178,11 @@ server.listen(port, async () => {
|
|||
const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts");
|
||||
const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts");
|
||||
jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob);
|
||||
// Federation keypair backfill — registered only when federation is on.
|
||||
if (process.env.FEDERATION_ENABLED === "true") {
|
||||
const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts");
|
||||
jobs.push(backfillUserKeypairsJob);
|
||||
}
|
||||
|
||||
const boss = createBoss(getDatabaseUrl());
|
||||
await startWorker(boss, jobs);
|
||||
|
|
@ -186,4 +191,12 @@ server.listen(port, async () => {
|
|||
const { setBoss } = await import("./app/lib/boss.server.ts");
|
||||
setBoss(boss);
|
||||
logger.info("Background job worker started");
|
||||
|
||||
// One-shot federation keypair backfill per startup (spec: existing
|
||||
// users get keys before any federation traffic). Each run only
|
||||
// touches users whose public_key IS NULL, so repeats are no-ops.
|
||||
if (process.env.FEDERATION_ENABLED === "true") {
|
||||
await boss.send("backfill-user-keypairs", {});
|
||||
logger.info("federation keypair backfill enqueued");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ Inbound signature verification uses the actor's public key from their actor obje
|
|||
|
||||
## Open Questions
|
||||
|
||||
- **`activities.owner_id` is NOT NULL but remote-ingested rows have no local
|
||||
owner** (surfaced during task 2.3, 2026-06-06). Options: make `owner_id`
|
||||
nullable with a check constraint (`owner_id IS NOT NULL OR remote_actor_iri
|
||||
IS NOT NULL`), or key remote rows purely off `remote_actor_iri` in a way
|
||||
that never touches owner-joined queries. Decide in task 7.2 (ingestion)
|
||||
before any remote row is written; the columns landed in 2.3 don't prejudge
|
||||
either option.
|
||||
|
||||
- Custom AS extension for activity-type vs. plain `Create(Note)`? Mastodon will only render Notes; an extension type means non-trails clients see nothing useful. Lean toward `Create(Note)` with structured metadata in `attachment` so Mastodon shows the text + GPX link, and trails clients consuming the outbox can read the structured fields.
|
||||
- Per-instance inbox vs per-user inbox? Mastodon supports a "shared inbox" optimization. Worth doing for delivery efficiency once we have any volume; v1 can use per-user inboxes only.
|
||||
- How does the trails-to-trails check interact with self-hosters who fork or rebrand? Probably the `software` field is `"trails.cool"` (or a derivative we list) and the `/.well-known/trails-cool` endpoint is the authoritative discovery. Tasks phase decides the exact shape.
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
## 2. Schema
|
||||
|
||||
- [ ] 2.1 Add `users.public_key TEXT NULL`, `users.private_key_encrypted TEXT NULL` (encrypted with `FEDERATION_KEY_ENCRYPTION_KEY`)
|
||||
- [ ] 2.2 Add `remote_actors` table: `actor_iri PK`, `display_name`, `username`, `domain`, `avatar_url`, `inbox_url`, `outbox_url`, `public_key`, `software` (the discovery field), `last_polled_at`, `created_at`
|
||||
- [ ] 2.3 Extend `journal.activities` with `remote_origin_iri TEXT NULL UNIQUE`, `remote_actor_iri TEXT NULL`, `audience TEXT NOT NULL DEFAULT 'public'`
|
||||
- [ ] 2.4 Run `pnpm db:push` locally and confirm migration is clean
|
||||
- [ ] 2.5 One-shot pg-boss job `backfill-user-keypairs` that generates a keypair for every existing user lacking one. Runs once at deploy when feature flag flips on
|
||||
- [x] 2.1 Add `users.public_key TEXT NULL`, `users.private_key_encrypted TEXT NULL` (encrypted with `FEDERATION_KEY_ENCRYPTION_KEY`)
|
||||
- [x] 2.2 Add `remote_actors` table: `actor_iri PK`, `display_name`, `username`, `domain`, `avatar_url`, `inbox_url`, `outbox_url`, `public_key`, `software` (the discovery field), `last_polled_at`, `created_at`
|
||||
- [x] 2.3 Extend `journal.activities` with `remote_origin_iri TEXT NULL UNIQUE`, `remote_actor_iri TEXT NULL`, `audience TEXT NOT NULL DEFAULT 'public'`
|
||||
- [x] 2.4 Run `pnpm db:push` locally and confirm migration is clean
|
||||
- [x] 2.5 One-shot pg-boss job `backfill-user-keypairs` that generates a keypair for every existing user lacking one. Runs once at deploy when feature flag flips on
|
||||
|
||||
## 3. Identity surface
|
||||
|
||||
|
|
|
|||
|
|
@ -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