-
+
{@render children()}
diff --git a/src/routes/.well-known/notes-identity/[handle]/+server.ts b/src/routes/.well-known/notes-identity/[handle]/+server.ts
index 35ff4a0..ef2d49a 100644
--- a/src/routes/.well-known/notes-identity/[handle]/+server.ts
+++ b/src/routes/.well-known/notes-identity/[handle]/+server.ts
@@ -2,6 +2,7 @@ import { json } from "@sveltejs/kit";
import { db } from "$lib/server/db";
import { users, devices } from "$lib/server/db/schema";
import { eq } from "drizzle-orm";
+import { env } from "$env/dynamic/private";
export async function GET({ params }) {
const { handle } = params;
@@ -26,17 +27,15 @@ export async function GET({ params }) {
return new Response("Not found", { status: 404 });
}
- const userDevices = await db.query.devices.findMany({
- where: eq(devices.userId, user.id),
- });
+ // Return public identity
+ // IMPORTANT: Return the FULL federated handle so other servers know exactly who this is.
+ // e.g. @bob -> @bob:localhost:5174
+ const fullHandle = `@${user.username}:${env.SERVER_DOMAIN || "localhost:5173"}`;
return json({
id: user.id,
- handle: `@${user.username}`, // Canonical handle
+ handle: fullHandle,
publicKey: user.publicKey,
- devices: userDevices.map((d) => ({
- device_id: d.deviceId,
- public_key: d.publicKey,
- })),
+ devices: [], // TODO: fetch devices
});
}
diff --git a/src/routes/api/notes/[id]/leave/+server.ts b/src/routes/api/notes/[id]/leave/+server.ts
new file mode 100644
index 0000000..694c6cb
--- /dev/null
+++ b/src/routes/api/notes/[id]/leave/+server.ts
@@ -0,0 +1,62 @@
+import { json, error } from "@sveltejs/kit";
+import { db } from "$lib/server/db";
+import { notes, noteShares, members, documents } from "$lib/server/db/schema";
+import { eq, and } from "drizzle-orm";
+import { requireLogin } from "$lib/server/auth";
+
+/**
+ * Leave API endpoint
+ *
+ * POST: Leave a note (remove self as member)
+ */
+
+export async function POST({ params, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ // Find the note
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ // Can't leave if you're the owner
+ if (note.ownerId === user.id) {
+ throw error(
+ 400,
+ "Owner cannot leave their own note. Transfer ownership or delete the note instead.",
+ );
+ }
+
+ // Remove self from noteShares
+ await db
+ .delete(noteShares)
+ .where(
+ and(
+ eq(noteShares.noteId, noteId),
+ eq(noteShares.sharedWithUser, user.id),
+ ),
+ );
+
+ // Remove self from members table (federation)
+ await db
+ .delete(members)
+ .where(and(eq(members.docId, noteId), eq(members.userId, user.id)));
+
+ // Delete local copy of the note if it's a federated note we don't own
+ // Check if this is a federated note by looking at the documents table
+ const doc = await db.query.documents.findFirst({
+ where: eq(documents.id, noteId),
+ });
+
+ if (doc && doc.hostServer !== "local") {
+ // This is a federated note - delete our local copy
+ await db.delete(notes).where(eq(notes.id, noteId));
+ await db.delete(documents).where(eq(documents.id, noteId));
+ }
+
+ return json({ success: true, leftNoteId: noteId });
+}
diff --git a/src/routes/api/notes/[id]/members/+server.ts b/src/routes/api/notes/[id]/members/+server.ts
new file mode 100644
index 0000000..395edb4
--- /dev/null
+++ b/src/routes/api/notes/[id]/members/+server.ts
@@ -0,0 +1,179 @@
+import { json, error } from "@sveltejs/kit";
+import { db } from "$lib/server/db";
+import { notes, noteShares, members } from "$lib/server/db/schema";
+import { eq, and } from "drizzle-orm";
+import { requireLogin } from "$lib/server/auth";
+
+/**
+ * Members API endpoint
+ *
+ * GET: Get list of members for a note
+ * POST: Add a member to the note (owner only)
+ * DELETE: Remove a member from the note (owner only)
+ */
+
+export interface Member {
+ userId: string; // Federated handle or local user ID
+ role: string; // owner, writer, reader
+ addedAt?: string; // When they were added
+}
+
+// GET members list
+export async function GET({ params, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ // Check if user has access (owner or member)
+ const isOwner = note.ownerId === user.id;
+
+ // Get shares from noteShares table (for invite_only mode)
+ const shares = await db.query.noteShares.findMany({
+ where: eq(noteShares.noteId, noteId),
+ });
+
+ // Get members from members table (for federation)
+ const membersList = await db.query.members.findMany({
+ where: eq(members.docId, noteId),
+ });
+
+ // Build combined member list
+ const result: Member[] = [];
+
+ // Add owner first
+ result.push({
+ userId: note.ownerId,
+ role: "owner",
+ });
+
+ // Add invited users from noteShares
+ for (const share of shares) {
+ if (share.sharedWithUser !== note.ownerId) {
+ result.push({
+ userId: share.sharedWithUser,
+ role: share.permissions === "write" ? "writer" : "reader",
+ addedAt: share.createdAt?.toISOString(),
+ });
+ }
+ }
+
+ // Add federated members
+ for (const member of membersList) {
+ // Avoid duplicates
+ if (!result.find((m) => m.userId === member.userId)) {
+ result.push({
+ userId: member.userId,
+ role: member.role,
+ addedAt: member.createdAt?.toISOString(),
+ });
+ }
+ }
+
+ return json({
+ noteId,
+ isOwner,
+ accessLevel: note.accessLevel,
+ members: result,
+ });
+}
+
+// POST add member
+export async function POST({ params, request, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ const body = await request.json();
+ const { userId, role = "writer" } = body;
+
+ if (!userId) {
+ throw error(400, "userId is required");
+ }
+
+ // Find the note and verify ownership
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ if (note.ownerId !== user.id) {
+ throw error(403, "Only the owner can add members");
+ }
+
+ // Add to noteShares for invite_only mode
+ const shareId = crypto.randomUUID();
+ await db
+ .insert(noteShares)
+ .values({
+ id: shareId,
+ noteId,
+ sharedWithUser: userId,
+ encryptedKey: "", // Will be populated when they request access
+ permissions: role === "reader" ? "read" : "write",
+ createdAt: new Date(),
+ })
+ .onConflictDoNothing();
+
+ // If invite_only mode is not set, set it
+ if (note.accessLevel === "private") {
+ await db
+ .update(notes)
+ .set({ accessLevel: "invite_only", updatedAt: new Date() })
+ .where(eq(notes.id, noteId));
+ }
+
+ return json({ success: true, userId, role });
+}
+
+// DELETE remove member
+export async function DELETE({ params, request, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ const url = new URL(request.url);
+ const userId = url.searchParams.get("userId");
+
+ if (!userId) {
+ throw error(400, "userId query parameter is required");
+ }
+
+ // Find the note and verify ownership
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ if (note.ownerId !== user.id) {
+ throw error(403, "Only the owner can remove members");
+ }
+
+ if (userId === note.ownerId) {
+ throw error(400, "Cannot remove the owner");
+ }
+
+ // Remove from noteShares
+ await db
+ .delete(noteShares)
+ .where(
+ and(eq(noteShares.noteId, noteId), eq(noteShares.sharedWithUser, userId)),
+ );
+
+ // Remove from members table (federation)
+ await db
+ .delete(members)
+ .where(and(eq(members.docId, noteId), eq(members.userId, userId)));
+
+ return json({ success: true, removedUserId: userId });
+}
diff --git a/src/routes/api/notes/[id]/share/+server.ts b/src/routes/api/notes/[id]/share/+server.ts
new file mode 100644
index 0000000..4814b7d
--- /dev/null
+++ b/src/routes/api/notes/[id]/share/+server.ts
@@ -0,0 +1,185 @@
+import { json, error } from "@sveltejs/kit";
+import { db } from "$lib/server/db";
+import { notes, noteShares, members, documents } from "$lib/server/db/schema";
+import { eq, and } from "drizzle-orm";
+import { requireLogin } from "$lib/server/auth";
+import { env } from "$env/dynamic/private";
+import {
+ fetchUserIdentity,
+ encryptDocumentKeyForUser,
+} from "$lib/server/federation";
+
+/**
+ * Share API endpoint
+ *
+ * POST: Update sharing settings for a note
+ * GET: Get current sharing settings
+ */
+
+export interface ShareSettings {
+ accessLevel: "private" | "invite_only" | "authenticated" | "open";
+ invitedUsers?: string[]; // Federated handles like @user:domain.com
+}
+
+// GET current share settings
+export async function GET({ params, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ if (note.ownerId !== user.id) {
+ throw error(403, "Only the owner can view share settings");
+ }
+
+ // Get invited users for this note
+ const shares = await db.query.noteShares.findMany({
+ where: eq(noteShares.noteId, noteId),
+ });
+
+ return json({
+ accessLevel: note.accessLevel || "private",
+ invitedUsers: shares.map((s) => s.sharedWithUser),
+ });
+}
+
+// POST update share settings
+export async function POST({ params, request, locals }) {
+ const { user } = requireLogin();
+ const { id: noteId } = params;
+
+ const body = await request.json();
+ const { accessLevel, invitedUsers } = body as ShareSettings;
+
+ // Validate access level
+ if (
+ !["private", "invite_only", "authenticated", "open"].includes(accessLevel)
+ ) {
+ throw error(400, "Invalid access level");
+ }
+
+ // Find the note
+ const note = await db.query.notes.findFirst({
+ where: eq(notes.id, noteId),
+ });
+
+ if (!note) {
+ throw error(404, "Note not found");
+ }
+
+ if (note.ownerId !== user.id) {
+ throw error(403, "Only the owner can update share settings");
+ }
+
+ // Get the document key (encrypted for owner)
+ const encryptedDocKey = note.documentKeyEncrypted || note.encryptedKey;
+ const serverDomain = env["SERVER_DOMAIN"] || "localhost:5173";
+
+ // Update note access level
+ await db
+ .update(notes)
+ .set({
+ accessLevel,
+ updatedAt: new Date(),
+ })
+ .where(eq(notes.id, noteId));
+
+ // Track failed invites for response
+ const failedInvites: string[] = [];
+ const successfulInvites: string[] = [];
+
+ // Handle invited users for invite_only mode
+ if (
+ accessLevel === "invite_only" &&
+ invitedUsers &&
+ invitedUsers.length > 0
+ ) {
+ // Clear existing shares (we'll re-add them)
+ await db.delete(noteShares).where(eq(noteShares.noteId, noteId));
+
+ // Add new shares with encrypted keys
+ for (const userHandle of invitedUsers) {
+ const shareId = crypto.randomUUID();
+ let encryptedKey = "";
+
+ // Try to fetch user's public key and encrypt document key
+ try {
+ const identity = await fetchUserIdentity(userHandle, serverDomain);
+ if (identity) {
+ const encrypted = encryptDocumentKeyForUser(
+ encryptedDocKey,
+ identity,
+ );
+ if (encrypted) {
+ encryptedKey = encrypted;
+ successfulInvites.push(userHandle);
+
+ // Also add to members table for federation
+ await db
+ .insert(members)
+ .values({
+ docId: noteId,
+ userId: identity.handle || userHandle,
+ deviceId: "primary",
+ role: "writer",
+ encryptedKeyEnvelope: encryptedKey,
+ createdAt: new Date(),
+ })
+ .onConflictDoNothing();
+ } else {
+ failedInvites.push(userHandle);
+ }
+ } else {
+ // User not found - still add share, key will be generated on join
+ failedInvites.push(userHandle);
+ }
+ } catch (err) {
+ console.error(`Failed to encrypt key for ${userHandle}:`, err);
+ failedInvites.push(userHandle);
+ }
+
+ // Always store the share record (even if key encryption failed)
+ await db.insert(noteShares).values({
+ id: shareId,
+ noteId,
+ sharedWithUser: userHandle,
+ encryptedKey,
+ permissions: "write",
+ createdAt: new Date(),
+ });
+ }
+ } else if (accessLevel !== "invite_only") {
+ // Clear invited users if not in invite_only mode
+ await db.delete(noteShares).where(eq(noteShares.noteId, noteId));
+ await db.delete(members).where(eq(members.docId, noteId));
+ }
+
+ // Also update the documents table if it exists (for federation)
+ const doc = await db.query.documents.findFirst({
+ where: eq(documents.id, noteId),
+ });
+
+ if (doc) {
+ await db
+ .update(documents)
+ .set({
+ accessLevel,
+ updatedAt: new Date(),
+ })
+ .where(eq(documents.id, noteId));
+ }
+
+ return json({
+ success: true,
+ accessLevel,
+ invitedUsers: invitedUsers || [],
+ successfulInvites,
+ failedInvites,
+ });
+}
diff --git a/src/routes/client/doc/[doc_id]/events/+server.ts b/src/routes/client/doc/[doc_id]/events/+server.ts
index 8d212b5..178e086 100644
--- a/src/routes/client/doc/[doc_id]/events/+server.ts
+++ b/src/routes/client/doc/[doc_id]/events/+server.ts
@@ -1,42 +1,98 @@
import { db } from "$lib/server/db";
-import { federatedOps } from "$lib/server/db/schema";
+import { federatedOps, documents } from "$lib/server/db/schema";
import { eq, gt, asc, and } from "drizzle-orm";
import type { RequestHandler } from "./$types";
-
+import { error } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ params, url }) => {
const { doc_id } = params;
const since = url.searchParams.get("since");
- let lastTs = since ? parseInt(since) : Date.now();
+ // Default to 0 (beginning of time) to fetch full history if 'since' is not provided.
+ // This ensures that when a client connects (especially for the first time),
+ // it receives all existing ops to reconstruct the document state.
+ let lastTs = since ? parseInt(since) : 0;
+
+ console.log(`[EVENTS] Connection request for ${doc_id}, since=${since}`);
+
+ const doc = await db.query.documents.findFirst({
+ where: eq(documents.id, doc_id),
+ });
+
+ if (!doc) {
+ console.error(`[EVENTS] Document not found: ${doc_id}`);
+ throw error(404, "Document not found");
+ }
+
+ const isRemote = doc && doc.hostServer !== "local";
const stream = new ReadableStream({
async start(controller) {
while (true) {
try {
- // Check if client is still connected?
- // ReadableStream doesn't inherently check unless we try to enqueue and it errors?
- // SvelteKit/Node might abort controller?
-
- // Poll
- // Fetch ops newer than lastTs for this doc
- const newOps = await db.query.federatedOps.findMany({
- where: and(
- eq(federatedOps.docId, doc_id),
- gt(federatedOps.lamportTs, lastTs),
- ),
- orderBy: [asc(federatedOps.lamportTs)],
- });
-
- if (newOps.length > 0) {
- const message = JSON.stringify(newOps);
- controller.enqueue(`data: ${message}\n\n`);
- // Update lastTs to the max ts found
- const maxTs = Math.max(...newOps.map((o) => o.lamportTs));
- if (maxTs > lastTs) lastTs = maxTs;
+ if (isRemote) {
+ // Poll remote server
+ // Ideally we'd subscribe to SSE, but for MVP polling is safer/easier
+ // We need to sign this request? Or is it public?
+ // Federation ops endpoint currently requires signature.
+
+ // Note: Efficient way would be to proxy the SSE connection directly?
+ // But we need to sign the request as the server.
+
+ // Let's implement polling for now to match local logic
+ const remoteUrl = `http://${doc.hostServer}/federation/doc/${encodeURIComponent(doc_id)}/ops?since=${lastTs}`;
+
+ // GET request doesn't have body, but we need headers.
+ // Ops endpoint checks signature on headers.
+ // It validates against NO body for GET?
+ // Checking ops/+server.ts: verifyServerRequest checks body?
+ // Wait, verifyServerRequest uses JSON.stringify(payload).
+ // If payload is empty body, verify logic needs to handle that.
+ // GET /ops logic in previous step didn't call verifyServerRequest.
+ // Let's check ops/+server.ts content again.
+ // GET handler checks DB directly. It does NOT call verifyServerRequest.
+ // So it's effectively public? Or relies on something else?
+ // It just returns ops.
+ // Ops are encrypted. So maybe it's fine.
+ // IF it's public, we don't need signature.
+
+ // console.log(`[CLIENT] Polling remote events from ${remoteUrl}`);
+ const res = await fetch(remoteUrl);
+ if (res.ok) {
+ const data = await res.json();
+ if (data.ops && data.ops.length > 0) {
+ console.log(`[CLIENT] Received ${data.ops.length} remote ops`);
+ const message = JSON.stringify(data.ops);
+ controller.enqueue(`data: ${message}\n\n`);
+ const maxTs = Math.max(
+ ...data.ops.map((o: any) => o.lamportTs),
+ );
+ if (maxTs > lastTs) lastTs = maxTs;
+ }
+ } else {
+ console.warn(`[CLIENT] Remote polling failed: ${res.status}`);
+ }
+ } else {
+ // Local polling (existing logic)
+ const newOps = await db.query.federatedOps.findMany({
+ where: and(
+ eq(federatedOps.docId, doc_id),
+ gt(federatedOps.lamportTs, lastTs),
+ ),
+ orderBy: [asc(federatedOps.lamportTs)],
+ });
+
+ if (newOps.length > 0) {
+ const message = JSON.stringify(newOps);
+ controller.enqueue(`data: ${message}\n\n`);
+ // Update lastTs to the max ts found
+ const maxTs = Math.max(...newOps.map((o) => o.lamportTs));
+ if (maxTs > lastTs) lastTs = maxTs;
+ }
}
- await new Promise((r) => setTimeout(r, 1000));
+ await new Promise((r) => setTimeout(r, 50));
} catch (e) {
// Error or closed?
+ console.error("Stream error:", e);
controller.close();
break;
}
diff --git a/src/routes/client/doc/[doc_id]/push/+server.ts b/src/routes/client/doc/[doc_id]/push/+server.ts
index 72029bd..953c590 100644
--- a/src/routes/client/doc/[doc_id]/push/+server.ts
+++ b/src/routes/client/doc/[doc_id]/push/+server.ts
@@ -1,6 +1,8 @@
-import { json } from "@sveltejs/kit";
+import { json, error } from "@sveltejs/kit";
import { db } from "$lib/server/db";
-import { federatedOps } from "$lib/server/db/schema";
+import { federatedOps, documents } from "$lib/server/db/schema";
+import { eq } from "drizzle-orm";
+import { signServerRequest } from "$lib/server/identity";
export async function POST({ params, request, locals }) {
const { doc_id } = params;
@@ -9,27 +11,84 @@ export async function POST({ params, request, locals }) {
// Op structure: { op_id, actor_id, lamport_ts, encrypted_payload, signature }
if (!locals.user) {
- // Validation check (auth)
- // Only members can write?
- // Check member role.
+ throw error(401, "Unauthorized");
}
- // Store Op
- await db
- .insert(federatedOps)
- .values({
- id: op.op_id,
- docId: doc_id,
- opId: op.op_id,
- actorId: op.actor_id,
- lamportTs: op.lamport_ts,
- payload: op.encrypted_payload, // or 'payload' in DB
- signature: op.signature,
- })
- .onConflictDoNothing();
-
- // Trigger SSE?
- // If using in-memory bus, emit here.
+ // Check if doc is remote
+ const doc = await db.query.documents.findFirst({
+ where: eq(documents.id, doc_id),
+ });
+
+ if (doc && doc.hostServer !== "local") {
+ // Proxy to remote server
+ console.log(
+ `[CLIENT] Proxying push to remote server: ${doc.hostServer} for ${doc_id}`,
+ );
+
+ const remoteUrl = `http://${doc.hostServer}/federation/doc/${encodeURIComponent(doc_id)}/ops`;
+ const payload = { ops: [op] }; // Federation endpoint expects array of ops
+
+ console.log(`[CLIENT] Signing request...`);
+ const {
+ signature,
+ timestamp,
+ domain: requestDomain,
+ } = await signServerRequest(payload);
+
+ console.log(`[CLIENT] Sending fetch to ${remoteUrl}`);
+ const res = await fetch(remoteUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-notes-signature": signature,
+ "x-notes-timestamp": timestamp.toString(),
+ "x-notes-domain": requestDomain,
+ },
+ body: JSON.stringify(payload),
+ });
+
+ console.log(`[CLIENT] Remote response status: ${res.status}`);
+ if (!res.ok) {
+ const text = await res.text();
+ console.error(
+ `[CLIENT] Failed to push to remote server: ${res.status}`,
+ text,
+ );
+ throw error(500, "Failed to push to remote server");
+ }
+
+ // We successfully pushed to remote.
+ // Do we store it locally too?
+ // Yes, otherwise we won't see our own changes if we reload/poll?
+ // But strictly speaking, we should receive it back via sync/events.
+ // However, for latency, we might want to store it.
+ // BUT, if we store it, we might duplicate it when we poll?
+ // `onConflictDoNothing` handles duplicates.
+ // So safe to store locally too.
+ }
+
+ // Store Op locally (even if remote, to cache/optimistic update)
+ try {
+ console.log(
+ `[CLIENT] Inserting op ${op.op_id} into federatedOps (docId: ${doc_id})`,
+ );
+ await db
+ .insert(federatedOps)
+ .values({
+ id: op.op_id,
+ docId: doc_id,
+ opId: op.op_id,
+ actorId: op.actor_id,
+ lamportTs: op.lamport_ts,
+ payload: op.encrypted_payload, // or 'payload' in DB
+ signature: op.signature,
+ })
+ .onConflictDoNothing();
+ console.log(`[CLIENT] Local insertion successful for ${op.op_id}`);
+ } catch (err) {
+ console.error(`[CLIENT] Local insertion failed for ${op.op_id}:`, err);
+ throw error(500, "Failed to store operation locally");
+ }
return json({ success: true });
}
diff --git a/src/routes/federation/doc/[doc_id]/join/+server.ts b/src/routes/federation/doc/[doc_id]/join/+server.ts
index ebe163f..4cca924 100644
--- a/src/routes/federation/doc/[doc_id]/join/+server.ts
+++ b/src/routes/federation/doc/[doc_id]/join/+server.ts
@@ -1,9 +1,14 @@
import { json, error } from "@sveltejs/kit";
import { getServerIdentity } from "$lib/server/identity";
-import { verify } from "$lib/crypto";
+import { verify, decryptKeyForDevice } from "$lib/crypto";
import { db } from "$lib/server/db";
-import { documents, members, notes } from "$lib/server/db/schema";
+import { documents, members, notes, users } from "$lib/server/db/schema";
import { eq, and, inArray } from "drizzle-orm";
+import {
+ fetchUserIdentity,
+ generateKeyEnvelopesForUsers,
+} from "$lib/server/federation";
+import { parseNoteId } from "$lib/noteId";
// Helper to verify request signature
async function verifyServerRequest(request: Request, payload: any) {
@@ -44,32 +49,81 @@ async function verifyServerRequest(request: Request, payload: any) {
export async function POST({ params, request }) {
const { doc_id } = params;
+ console.log("=== JOIN ENDPOINT START ===");
+ console.log(" doc_id from params:", doc_id);
+ console.log(" decoded doc_id:", decodeURIComponent(doc_id));
+
const body = await request.json();
const { requesting_server, users: joiningUsers } = body;
+ console.log(" requesting_server:", requesting_server);
+ console.log(" joiningUsers:", joiningUsers);
// Verify signature
- await verifyServerRequest(request, body);
+ const remoteServer = await verifyServerRequest(request, body);
+ console.log(" remoteServer verified:", remoteServer?.domain);
// 1. Check if doc exists
- // Note: querying 'documents' table. If using 'notes', switch to 'notes' or ensure 'documents' populated.
- // For now assuming 'documents' table is used for federation metadata.
- const doc = await db.query.documents.findFirst({
+ // The doc_id may be a full portable ID (e.g., bG9jYWxob3N0OjUxNzM~uuid) or just a UUID
+ // Try the full ID first, then try to parse and use UUID as fallback
+
+ // First try with the raw doc_id from params
+ console.log(" Searching for doc_id:", doc_id);
+ let doc = await db.query.documents.findFirst({
where: eq(documents.id, doc_id),
});
+ console.log(" documents.findFirst(doc_id):", doc?.id || "NOT FOUND");
+
+ let note = await db.query.notes.findFirst({
+ where: eq(notes.id, doc_id),
+ });
+ console.log(" notes.findFirst(doc_id):", note?.id || "NOT FOUND");
+
+ // Try with decoded doc_id (in case it was URL-encoded)
+ const decodedDocId = decodeURIComponent(doc_id);
+ if (!note && !doc && decodedDocId !== doc_id) {
+ console.log(" Trying decoded doc_id:", decodedDocId);
+ doc = await db.query.documents.findFirst({
+ where: eq(documents.id, decodedDocId),
+ });
+ console.log(" documents.findFirst(decoded):", doc?.id || "NOT FOUND");
- // Fallback to checking `notes` if `documents` empty?
- // If we haven't migrated existing notes to `documents`, check `notes`.
- let note;
- if (!doc) {
note = await db.query.notes.findFirst({
- where: eq(notes.id, doc_id),
+ where: eq(notes.id, decodedDocId),
});
- if (!note) throw error(404, "Document not found");
- // Implicitly hosted here if local note found?
- } else {
- note = await db.query.notes.findFirst({ where: eq(notes.id, doc_id) });
+ console.log(" notes.findFirst(decoded):", note?.id || "NOT FOUND");
+ }
+
+ // If not found with full ID, the ID might already exist as just a UUID (legacy)
+ if (!note && !doc) {
+ // Try parsing the portable ID to extract the UUID
+ const { uuid } = parseNoteId(decodedDocId);
+ console.log(" Parsed UUID from portable ID:", uuid);
+ if (uuid && uuid !== decodedDocId) {
+ doc = await db.query.documents.findFirst({
+ where: eq(documents.id, uuid),
+ });
+ console.log(" documents.findFirst(uuid):", doc?.id || "NOT FOUND");
+
+ note = await db.query.notes.findFirst({
+ where: eq(notes.id, uuid),
+ });
+ console.log(" notes.findFirst(uuid):", note?.id || "NOT FOUND");
+ }
}
+ if (!note && !doc) {
+ // List all notes in DB for debugging
+ const allNotes = await db.query.notes.findMany({ limit: 5 });
+ console.log(
+ " All notes in DB (first 5):",
+ allNotes.map((n) => n.id),
+ );
+ console.error(` Document not found: ${doc_id}`);
+ throw error(404, "Document not found");
+ }
+
+ console.log(" Found note:", note?.id, "accessLevel:", note?.accessLevel);
+
// 2. Check permissions based on access_level
const accessLevel = note?.accessLevel || doc?.accessLevel || "private";
@@ -104,26 +158,133 @@ export async function POST({ params, request }) {
});
}
- // 4. For authenticated/open notes, generate encrypted keys for joining users
- // Need to fetch their public keys from their server
+ // 3. For authenticated/open notes, generate encrypted keys for joining users
const snapshot = note?.loroSnapshot || null;
- const documentKey = note?.documentKeyEncrypted || note?.encryptedKey;
+ const encryptedDocKey = note?.documentKeyEncrypted || note?.encryptedKey;
- if (!documentKey) {
+ if (!encryptedDocKey) {
throw error(500, "Document key not found");
}
- // For now, return a temporary solution: let client generate own key
- // TODO: Implement proper key exchange:
- // 1. Fetch user public keys from requesting_server/.well-known/notes-identity/[user]
- // 2. Decrypt document key (if encrypted for owner)
- // 3. Re-encrypt for each joining user's public key
- // 4. Return encrypted envelopes
+ // Get the owner's private key to decrypt the document key
+ // Note: In a real E2EE system, the server wouldn't have access to decrypted keys
+ // This is a simplified approach where the server can re-encrypt for new users
+ const owner = await db.query.users.findFirst({
+ where: eq(users.id, note?.ownerId || ""),
+ });
+
+ if (!owner) {
+ throw error(500, "Document owner not found");
+ }
+
+ // For authenticated/open notes, we'll generate envelopes by:
+ // 1. Fetching user public keys from requesting_server
+ // 2. Encrypting the document key for each user
+
+ const serverIdentity = await getServerIdentity();
+
+ // Decrypt the doc key first!
+ // Decrypt the doc key first!
+ let rawDocKey = encryptedDocKey;
+ console.log(`[JOIN] encryptedDocKey Length: ${encryptedDocKey.length}`);
+
+ if (encryptedDocKey.length > 44) {
+ if (owner.privateKeyEncrypted) {
+ console.log(
+ `[JOIN] Owner PrivKey Length: ${owner.privateKeyEncrypted.length}`,
+ );
+ try {
+ console.log(`[JOIN] Decrypting owner key for re-encryption...`);
+ rawDocKey = decryptKeyForDevice(
+ encryptedDocKey,
+ owner.privateKeyEncrypted,
+ );
+ console.log(`[JOIN] Decrypted Raw Key Length: ${rawDocKey.length}`);
+ } catch (e) {
+ console.error(`[JOIN] Failed to decrypt owner key:`, e);
+ throw error(500, "Failed to decrypt note key for sharing");
+ }
+ } else {
+ console.error(`[JOIN] Owner has no private key! CANNOT DECRYPT.`);
+ // CRITICAL: Do not allow double encryption. Fail here.
+ throw error(
+ 500,
+ "Owner missing private key - cannot share authenticated note",
+ );
+ }
+ } else {
+ console.log(
+ `[JOIN] Key is already raw (Length: ${encryptedDocKey.length})`,
+ );
+ }
+
+ // Debug Identity Fetching
+ for (const handle of joiningUsers) {
+ const id = await fetchUserIdentity(handle, requesting_server);
+ console.log(
+ ` [DEBUG] Fetched Identity for ${handle}:`,
+ JSON.stringify(id),
+ );
+ if (id?.publicKey) {
+ console.log(` [DEBUG] Public Key for ${handle}: ${id.publicKey}`);
+ }
+ }
+
+ const envelopes = await generateKeyEnvelopesForUsers(
+ rawDocKey, // Now passing the RAW key
+ joiningUsers,
+ requesting_server,
+ );
+
+ // Ensure documents entry exists (required for members FK constraint)
+ // Use the actual note ID (which may be a portable ID)
+ const noteId = note?.id || doc_id;
+ const docEntry = await db.query.documents.findFirst({
+ where: eq(documents.id, noteId),
+ });
+
+ if (!docEntry) {
+ console.log(" Creating documents entry for:", noteId);
+ await db
+ .insert(documents)
+ .values({
+ id: noteId,
+ hostServer: "local",
+ ownerId: note?.ownerId || "",
+ title: note?.title || "Untitled",
+ accessLevel: accessLevel,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing();
+ }
+
+ // Also add the joining users as members
+ for (const envelope of envelopes) {
+ console.log(" Adding member:", envelope.user_id);
+ await db
+ .insert(members)
+ .values({
+ docId: noteId,
+ userId: envelope.user_id,
+ deviceId: envelope.device_id,
+ role: "writer",
+ encryptedKeyEnvelope: envelope.encrypted_key,
+ createdAt: new Date(),
+ })
+ .onConflictDoUpdate({
+ target: [members.docId, members.userId, members.deviceId],
+ set: {
+ encryptedKeyEnvelope: envelope.encrypted_key,
+ role: "writer",
+ },
+ });
+ }
return json({
doc_id,
snapshot,
- envelopes: [], // Empty for now - client will generate key
+ envelopes,
title: note?.title || "Untitled",
ownerId: note?.ownerId,
accessLevel,
diff --git a/src/routes/federation/doc/[doc_id]/ops/+server.ts b/src/routes/federation/doc/[doc_id]/ops/+server.ts
index b678087..2df276a 100644
--- a/src/routes/federation/doc/[doc_id]/ops/+server.ts
+++ b/src/routes/federation/doc/[doc_id]/ops/+server.ts
@@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit";
import { verify } from "$lib/crypto";
import { db } from "$lib/server/db";
import { federatedOps } from "$lib/server/db/schema";
-import { eq, gt, asc } from "drizzle-orm";
+import { eq, gt, asc, and } from "drizzle-orm";
import { signServerRequest } from "$lib/server/identity";
// Helper for verification (reuse from Join or export it? Duplicate for now to avoid logic split)
@@ -39,40 +39,43 @@ export async function GET({ params, url }) {
const sinceTs = since ? parseInt(since) : 0;
const ops = await db.query.federatedOps.findMany({
- where: gt(federatedOps.lamportTs, sinceTs), // Actually need to filter by doc_id too
- // TODO: fix query to use AND
+ where: and(
+ eq(federatedOps.docId, doc_id),
+ gt(federatedOps.lamportTs, sinceTs),
+ ),
+ orderBy: [asc(federatedOps.lamportTs)],
});
- // Fix:
- // where: and(eq(federatedOps.docId, doc_id), gt(federatedOps.lamportTs, sinceTs))
-
- // Sort by lamportTs
- // orderBy: [asc(federatedOps.lamportTs)]
-
return json({
- ops: [], // TODO: correct query above
- server_version: Date.now(), // placeholder
+ ops,
+ server_version: Date.now(),
});
}
// PUSH Ops
export async function POST({ params, request }) {
const { doc_id } = params;
+ console.log(`[FED] Received ops push for ${doc_id}`);
+
const body = await request.json();
const { ops } = body;
+ console.log(`[FED] Ops count: ${ops?.length}`);
- await verifyServerRequest(request, body);
+ try {
+ await verifyServerRequest(request, body);
+ console.log(`[FED] Verification successful`);
+ } catch (e) {
+ console.error(`[FED] Verification failed:`, e);
+ throw e;
+ }
if (!Array.isArray(ops)) throw error(400);
- for (const op of ops) {
- // Verify op signature?
- // Spec: "Receiving server verifies signatures" (of OP).
- // Op structure: { doc_id, op_id, actor_id, signature, ... }
- // Verify sig using User's device key?
- // We need to fetch User/Device key.
- // For MVP, just store.
+ // Validate that the document exists first?
+ // Ideally yes, but maybe we just accept ops for known docs.
+ for (const op of ops) {
+ console.log(`[FED] Inserting op ${op.op_id}`);
await db
.insert(federatedOps)
.values({
@@ -81,11 +84,12 @@ export async function POST({ params, request }) {
opId: op.op_id,
actorId: op.actor_id,
lamportTs: op.lamport_ts,
- payload: op.encrypted_payload,
+ payload: op.encrypted_payload, // Note: client sends 'encrypted_payload' in JSON, but DB has 'payload'
signature: op.signature,
})
.onConflictDoNothing();
}
+ console.log(`[FED] Ops inserted successfully`);
return json({ success: true });
}
diff --git a/src/routes/federation/import/+page.server.ts b/src/routes/federation/import/+page.server.ts
index d32f5db..ce3bdd4 100644
--- a/src/routes/federation/import/+page.server.ts
+++ b/src/routes/federation/import/+page.server.ts
@@ -37,28 +37,13 @@ export async function load({ url, locals }) {
throw redirect(302, `/notes/${doc_id}`);
}
- // Perform Join
- // 1. Fetch user's devices to request keys for?
- // Actually, Server A (Host) needs to know which users to generate envelopes for.
- // If User B is joining, we send User B's ID (federated ID: @user:domain).
- // But Server A might not know User B's device keys yet?
- // "Join" implies we are asking for keys.
- // Usually we exchange keys first.
- // Spec: "Join... We expect them to be allowed...".
-
- // Complex part: How does Server A know User B's device public key to encrypt the note key?
- // Option A: User B published keys to Server A previously (via Join Request payload?).
- // Option B: Server A queries Server B Identity endpoint `/.well-known/notes-identity/user`.
-
- // Let's assume Option B: Host looks up Joiner's identity.
- // So we just send `users: ["bob"]` (local username or full handle?) -> Federated Handle `@bob:server-b.com`.
-
- const userHandle = `@${user.username}`; // Requesting for local user
+ // Construct the federated handle for the joining user
+ const userHandle = `@${user.username}:${identity.domain}`;
// Sign request
const payload = {
requesting_server: identity.domain,
- users: [userHandle], // List of users I am joining on behalf of
+ users: [userHandle], // Full federated handle
};
const { signature, timestamp, domain } = await signServerRequest(payload);
@@ -82,65 +67,69 @@ export async function load({ url, locals }) {
if (!res.ok) {
const text = await res.text();
console.error("Join failed:", text);
- throw error(res.status as any, "Failed to join document on host server");
+ throw error(res.status as any, `Failed to join document: ${text}`);
}
joinRes = await res.json();
- } catch (e) {
+ } catch (e: any) {
console.error("Join error:", e);
+ if (e.status) throw e; // Re-throw if it's already an error response
throw error(502, "Failed to contact host server");
}
// Process Response
- // { snapshot: ..., envelopes: [...] }
+ // { snapshot, envelopes: [{ user_id, device_id, encrypted_key }], title, accessLevel }
+
+ // Find the envelope for our user
+ const myEnvelope = joinRes.envelopes?.find(
+ (env: any) =>
+ env.user_id === userHandle ||
+ env.user_id === `@${user.username}` ||
+ env.user_id === user.username,
+ );
+
+ const encryptedKey = myEnvelope?.encrypted_key || "";
// Save Document Metadata
await db.insert(documents).values({
id: doc_id,
hostServer: host,
- ownerId: "unknown", // or fetch from host
- // ...
+ ownerId: joinRes.ownerId || "unknown",
+ title: joinRes.title || "Untitled",
+ accessLevel: joinRes.accessLevel || "authenticated",
});
- // Save Content (Snapshot)
- if (joinRes.snapshot) {
- await db
- .insert(notes)
- .values({
- id: doc_id,
- ownerId: user.id, // Local owner? Or proxy?
- // If we are replica, ownerId might be irrelevant or we keep original owner ID string?
- // Schema `notes.ownerId` is `text`.
- loroSnapshot: joinRes.snapshot,
- })
- .onConflictDoUpdate({
- target: notes.id,
- set: { loroSnapshot: joinRes.snapshot },
- });
- }
-
- // Save Envelopes
- // joinRes.envelopes: [{ user_id, device_id, encrypted_key }]
- // We need to map these to local `members` table.
-
- for (const env of joinRes.envelopes) {
- // user_id from host might be `@bob:server-b.com` or just `bob`?
- // Hosted returns what we asked or canonical.
-
- // We need to store it for OUR local user.
- // `members` table links to `users`? Schema check: `userId` is text, not reference?
- // Let's check schema.
+ // Save Content (Snapshot) - use empty snapshot if none provided
+ await db
+ .insert(notes)
+ .values({
+ id: doc_id,
+ ownerId: user.id, // Local user becomes local "owner" of this copy
+ title: joinRes.title || "Untitled",
+ encryptedKey, // The encrypted document key for this user
+ loroSnapshot: joinRes.snapshot || null,
+ accessLevel: joinRes.accessLevel || "authenticated",
+ })
+ .onConflictDoUpdate({
+ target: notes.id,
+ set: {
+ loroSnapshot: joinRes.snapshot || null,
+ encryptedKey,
+ updatedAt: new Date(),
+ },
+ });
+ // Save all envelopes to members table
+ for (const env of joinRes.envelopes || []) {
await db
.insert(members)
.values({
docId: doc_id,
- userId: user.id, // Map back to local ID? Or store federated ID?
- // If `members.userId` is used for auth checks, it better match `locals.user.id`.
- // But if it receives envelopes for multiple devices?
- deviceId: env.device_id,
- role: "writer", // Assume writer if joined?
+ userId: user.id, // Map to local user ID
+ deviceId: env.device_id || "primary",
+ role: "writer",
encryptedKeyEnvelope: env.encrypted_key,
+ createdAt: new Date(),
})
.onConflictDoNothing();
}
diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte
index 1cb4dba..564c392 100644
--- a/src/routes/notes/[id]/+page.svelte
+++ b/src/routes/notes/[id]/+page.svelte
@@ -1,10 +1,14 @@
@@ -19,13 +21,14 @@
{/each}
-