Major changes: - Restructure plugin architecture: moved federation logic into a dedicated `federation` plugin with Better Auth integration, defining schemas for server registry, key rotation, and blacklist management - Extract encryption layer: new `oven` plugin handles end-to-end encryption (E2EE) with OLM client/server implementations - Reorganize social features: consolidated social endpoints (posts, follows, blocks, mutes) and removed legacy plugin patterns in favor of unified plugin structure - Decentralized key management: refactored `keytools` and `keygen` to support federation key rotation with challenge tokens and health checks Infrastructure updates: - Upgrade dependencies: bump Better Auth to 1.6.9, React to 19.2.5, Next.js to 16.2.3, Tailwind to 4.2.4 - Add cryptographic libraries: @scure/bip39, @signalapp/libsignal-client, @matrix-org/matrix-sdk-crypto-wasm for enhanced federation security - Add utilities: base58-js, uuid for federation identifier handling - Update database schema with new federation tables (serverRegistry, rotateChallengeTokens, blacklistedServers) Minor updates: test suite alignment, storage client cleanup, PostFederationSchema refinements Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
No EOL
5.7 KiB
TypeScript
270 lines
No EOL
5.7 KiB
TypeScript
import { BetterAuthPluginDBSchema } from "better-auth";
|
|
import { z } from "zod";
|
|
|
|
const postContentBlockSchema = z.discriminatedUnion("type", [
|
|
z.object({
|
|
type: z.literal("text"),
|
|
value: z.string().min(1, "Text content cannot be empty"),
|
|
}),
|
|
z.object({
|
|
type: z.literal("image"),
|
|
url: z.url("Image must be a valid URL"),
|
|
index: z.number().min(0, "Index must be a positive number"),
|
|
size: z.number().min(0, "Size must be a positive number"),
|
|
}),
|
|
z.object({
|
|
type: z.literal("video"),
|
|
url: z.url("Video must be a valid URL"),
|
|
size: z.number().min(0, "Size must be a positive number"),
|
|
index: z.number().min(0, "Index must be a positive number"),
|
|
}),
|
|
z.object({
|
|
type: z.literal("audio"),
|
|
url: z.url("Audio must be a valid URL"),
|
|
size: z.number().min(0, "Size must be a positive number"),
|
|
}),
|
|
z.object({
|
|
type: z.literal("link"),
|
|
url: z.url("Link must be a valid URL"),
|
|
}),
|
|
], { error: 'Block "type" must be one of: text, image, video, audio, link' });
|
|
|
|
export const postContentSchema = z
|
|
.array(postContentBlockSchema, { error: "Post content must be an array of blocks" })
|
|
.min(1, "Post must contain at least one content block");
|
|
|
|
export default {
|
|
posts: {
|
|
fields: {
|
|
content: {
|
|
type: "json",
|
|
required: true,
|
|
index: false,
|
|
transform: {
|
|
output: (value) => {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
} catch {
|
|
throw new Error("Post content is not valid JSON");
|
|
}
|
|
|
|
const validated = postContentSchema.safeParse(parsed);
|
|
if (!validated.success) {
|
|
const issues = validated.error.issues
|
|
.map((i) => `[${i.path.join(".")}] ${i.message}`)
|
|
.join("; ");
|
|
throw new Error(`Invalid post content: ${issues}`);
|
|
}
|
|
|
|
return validated.data;
|
|
}
|
|
}
|
|
},
|
|
authorId: {
|
|
type: "string",
|
|
required: false,
|
|
index: false,
|
|
references: {
|
|
model: "user",
|
|
field: "id"
|
|
}
|
|
},
|
|
federatedAuthorId: {
|
|
type: "string",
|
|
required: false,
|
|
index: false,
|
|
},
|
|
published: {
|
|
type: "date",
|
|
required: true,
|
|
index: false,
|
|
},
|
|
// "isLocal" will be used to determine if the post should only exist
|
|
// on the local server or if it should be propagated to other servers
|
|
isLocal: {
|
|
type: "boolean",
|
|
required: true,
|
|
index: false,
|
|
defaultValue: false,
|
|
},
|
|
// "isPrivate" will be used to determine if the post should be visible only for the user's followers
|
|
// If "isLocal" is set to true and this to false, only users on the same server will be able to see the psot
|
|
isPrivate: {
|
|
type: "boolean",
|
|
required: false,
|
|
index: false,
|
|
defaultValue: false,
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
index: false
|
|
},
|
|
federationUrl: {
|
|
type: "string",
|
|
required: false,
|
|
index: true,
|
|
},
|
|
// This serves as a reference to the post on the original server this post came from
|
|
federationPostId: {
|
|
type: "string",
|
|
required: false,
|
|
index: true,
|
|
},
|
|
/**
|
|
* Base64-encoded Ed25519 detached signature produced client-side by
|
|
* the author's mnemonic-derived identity key. Covers the canonical
|
|
* post payload defined in `src/lib/identity/postSignature.ts`.
|
|
*
|
|
* Optional so federated/legacy posts that arrive without a per-user
|
|
* signature can still be stored, but locally-authored posts always
|
|
* have one — the createPost endpoint rejects requests that don't.
|
|
*/
|
|
authorSignature: {
|
|
type: "string",
|
|
required: false,
|
|
index: false,
|
|
},
|
|
}
|
|
},
|
|
follows: {
|
|
fields: {
|
|
followerId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
},
|
|
followingId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
},
|
|
accepted: {
|
|
type: "boolean",
|
|
required: true,
|
|
index: false,
|
|
defaultValue: false,
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
index: false
|
|
},
|
|
followerServerUrl: {
|
|
type: "string",
|
|
required: false,
|
|
index: true,
|
|
references: {
|
|
model: "serverRegistry",
|
|
field: "url"
|
|
}
|
|
},
|
|
followingServerUrl: {
|
|
type: "string",
|
|
required: false,
|
|
index: true,
|
|
references: {
|
|
model: "serverRegistry",
|
|
field: "url"
|
|
}
|
|
},
|
|
acknowledged: {
|
|
type: "boolean",
|
|
required: true,
|
|
index: false,
|
|
defaultValue: false,
|
|
},
|
|
}
|
|
},
|
|
deliveryJobs: {
|
|
fields: {
|
|
targetUrl: {
|
|
type: "string",
|
|
required: true,
|
|
index: false
|
|
},
|
|
// This could be encrypted, so we're not using a transform function to check for validity
|
|
payload: {
|
|
type: "string",
|
|
required: true,
|
|
index: false
|
|
},
|
|
attempts: {
|
|
type: "number",
|
|
required: true,
|
|
index: false,
|
|
defaultValue: 0,
|
|
},
|
|
lastAttemptedAt: {
|
|
type: "date",
|
|
required: false,
|
|
index: false,
|
|
},
|
|
nextAttemptAt: {
|
|
type: "date",
|
|
required: false,
|
|
index: false,
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
index: false
|
|
}
|
|
}
|
|
},
|
|
mutes: {
|
|
fields: {
|
|
userId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
references: {
|
|
model: "user",
|
|
field: "id"
|
|
}
|
|
},
|
|
mutedUserId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
references: {
|
|
model: "user",
|
|
field: "id"
|
|
}
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
index: false
|
|
}
|
|
}
|
|
},
|
|
blocks: {
|
|
fields: {
|
|
blockerId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
references: {
|
|
model: "user",
|
|
field: "id"
|
|
}
|
|
},
|
|
blockedUserId: {
|
|
type: "string",
|
|
required: true,
|
|
index: false,
|
|
references: {
|
|
model: "user",
|
|
field: "id"
|
|
}
|
|
},
|
|
createdAt: {
|
|
type: "date",
|
|
required: true,
|
|
index: false
|
|
}
|
|
}
|
|
}
|
|
} satisfies BetterAuthPluginDBSchema |