Permissions issues on my deployment server... so I guess in-memory it
Some checks are pending
Deploy Encrypted Todo App / build-and-push (push) Has started running

is!
This commit is contained in:
2025-06-16 10:55:14 -06:00
parent 7f4bb14b18
commit 81d8091354
7 changed files with 122 additions and 830 deletions

View File

@ -1,187 +1,31 @@
import { createClient } from "@libsql/client";
import "dotenv/config";
import { SignalClient } from "./signal-crypto.js";
import { v4 as uuidv4 } from "uuid";
export class EncryptedTodoService {
constructor() {
this.db = createClient({
url: `file:${process.env.SQLITE_DB_PATH}`,
});
this.users = new Map();
// In-memory storage using Maps
this.users = new Map(); // userId -> SignalClient
this.userIds = new Set(); // Set of all user IDs
this.todos = new Map(); // todoId -> todo object
this.todoGroups = new Map(); // todoGroupId -> group info
this.todoParticipants = new Map(); // todoGroupId -> Set of userIds
this.userTodos = new Map(); // userId -> Set of todoGroupIds
// Initialize database and migrate
this.initializeDatabase();
}
async initializeDatabase() {
// Create tables with new schema
await this.db.execute(`
CREATE TABLE IF NOT EXISTS users (
userId TEXT PRIMARY KEY
);
`);
await this.db.execute(`
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
todoGroupId TEXT,
userId TEXT,
encrypted TEXT,
createdAt TEXT,
originalText TEXT,
completed INTEGER,
sharedBy TEXT,
originalTodoId TEXT,
FOREIGN KEY(userId) REFERENCES users(userId)
);
`);
await this.db.execute(`
CREATE TABLE IF NOT EXISTS todo_groups (
todoGroupId TEXT PRIMARY KEY,
createdBy TEXT,
createdAt TEXT,
FOREIGN KEY(createdBy) REFERENCES users(userId)
);
`);
await this.db.execute(`
CREATE TABLE IF NOT EXISTS todo_participants (
todoGroupId TEXT,
userId TEXT,
joinedAt TEXT,
PRIMARY KEY(todoGroupId, userId),
FOREIGN KEY(todoGroupId) REFERENCES todo_groups(todoGroupId),
FOREIGN KEY(userId) REFERENCES users(userId)
);
`);
await this.db.execute(`
CREATE TABLE IF NOT EXISTS todo_shares (
todoId TEXT,
sharedWith TEXT,
FOREIGN KEY(todoId) REFERENCES todos(id),
FOREIGN KEY(sharedWith) REFERENCES users(userId)
);
`);
// Migrate existing data to new schema
await this.migrateToNewSchema();
// Load existing users from database on startup
await this.loadExistingUsers();
}
async migrateToNewSchema() {
// Check if we need to migrate by looking for todos without todoGroupId
const oldTodos = await this.db.execute(
"SELECT * FROM todos WHERE todoGroupId IS NULL",
);
if (oldTodos.rows.length > 0) {
console.log(`Migrating ${oldTodos.rows.length} todos to new schema...`);
for (const todo of oldTodos.rows) {
const todoGroupId = uuidv4();
const now = new Date().toISOString();
// If this is an original todo (not shared)
if (!todo.sharedBy && !todo.originalTodoId) {
// Create todo group
await this.db.execute(
"INSERT OR IGNORE INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)",
[todoGroupId, todo.userId, todo.createdAt || now],
);
// Add creator as participant
await this.db.execute(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
[todoGroupId, todo.userId, todo.createdAt || now],
);
// Update the todo with todoGroupId
await this.db.execute(
"UPDATE todos SET todoGroupId = ? WHERE id = ?",
[todoGroupId, todo.id],
);
// Find and migrate shared copies
const sharedCopies = await this.db.execute(
"SELECT * FROM todos WHERE originalTodoId = ?",
[todo.id],
);
for (const sharedTodo of sharedCopies.rows) {
// Add shared user as participant
await this.db.execute(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
[todoGroupId, sharedTodo.userId, sharedTodo.createdAt || now],
);
// Update shared todo with same todoGroupId
await this.db.execute(
"UPDATE todos SET todoGroupId = ? WHERE id = ?",
[todoGroupId, sharedTodo.id],
);
}
}
// If this is a shared todo without a group (shouldn't happen after above, but just in case)
else if (todo.originalTodoId && !todo.todoGroupId) {
const originalTodo = await this.db.execute(
"SELECT todoGroupId FROM todos WHERE id = ?",
[todo.originalTodoId],
);
if (
originalTodo.rows.length > 0 &&
originalTodo.rows[0].todoGroupId
) {
await this.db.execute(
"UPDATE todos SET todoGroupId = ? WHERE id = ?",
[originalTodo.rows[0].todoGroupId, todo.id],
);
// Add as participant if not already
await this.db.execute(
"INSERT OR IGNORE INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
[
originalTodo.rows[0].todoGroupId,
todo.userId,
todo.createdAt || now,
],
);
}
}
}
console.log("Migration completed!");
}
}
async loadExistingUsers() {
const existingUsers = await this.db.execute("SELECT userId FROM users");
for (const user of existingUsers.rows) {
if (!this.users.has(user.userId)) {
this.users.set(user.userId, SignalClient.create(user.userId));
console.log(`Loaded existing user: ${user.userId}`);
}
}
console.log("Initialized in-memory encrypted todo service");
}
async createUser(userId) {
await this.db.execute("INSERT OR IGNORE INTO users (userId) VALUES (?)", [
userId,
]);
this.userIds.add(userId);
if (!this.users.has(userId)) {
this.users.set(userId, SignalClient.create(userId));
this.userTodos.set(userId, new Set());
}
return { userId };
}
async getAllUsers() {
const result = await this.db.execute("SELECT userId FROM users");
return result.rows.map((u) => u.userId);
return Array.from(this.userIds);
}
async addTodo(userId, todoText) {
@ -192,30 +36,29 @@ export class EncryptedTodoService {
const now = new Date().toISOString();
// Create the todo group
await this.db.execute(
"INSERT INTO todo_groups (todoGroupId, createdBy, createdAt) VALUES (?, ?, ?)",
[todoGroupId, userId, now],
);
this.todoGroups.set(todoGroupId, {
todoGroupId,
createdBy: userId,
createdAt: now,
});
// Add the creator as a participant
await this.db.execute(
"INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
[todoGroupId, userId, now],
);
this.todoParticipants.set(todoGroupId, new Set([userId]));
// Create the todo entry for the creator
await this.db.execute(
"INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
todoId,
todoGroupId,
userId,
JSON.stringify(encryptedTodo),
now,
todoText,
0,
],
);
const todoKey = `${todoGroupId}_${userId}`;
this.todos.set(todoKey, {
id: todoId,
todoGroupId,
userId,
encrypted: encryptedTodo,
createdAt: now,
originalText: todoText,
completed: false,
});
// Add to user's todo list
this.userTodos.get(userId).add(todoGroupId);
return todoGroupId;
}
@ -227,84 +70,63 @@ export class EncryptedTodoService {
return [];
}
const rows = await this.db.execute("SELECT * FROM todos WHERE userId = ?", [
userId,
]);
const userTodoGroups = this.userTodos.get(userId) || new Set();
const todos = [];
for (const row of rows.rows) {
for (const todoGroupId of userTodoGroups) {
try {
// Skip todos without todoGroupId (migration didn't work)
if (!row.todoGroupId) {
console.warn(`Todo ${row.id} has no todoGroupId, skipping`);
const todoKey = `${todoGroupId}_${userId}`;
const todoData = this.todos.get(todoKey);
if (!todoData) {
console.warn(`Todo data not found for ${todoKey}`);
continue;
}
const decryptedText = client.decrypt(JSON.parse(row.encrypted));
// Get all participants in this todo group
const participants = await this.db.execute(
"SELECT userId FROM todo_participants WHERE todoGroupId = ?",
[row.todoGroupId],
);
// Get group creator
const groupInfo = await this.db.execute(
"SELECT createdBy FROM todo_groups WHERE todoGroupId = ?",
[row.todoGroupId],
const decryptedText = client.decrypt(todoData.encrypted);
const groupInfo = this.todoGroups.get(todoGroupId);
const participants = Array.from(
this.todoParticipants.get(todoGroupId) || new Set(),
);
todos.push({
id: row.todoGroupId, // Use todoGroupId as the primary identifier
id: todoGroupId, // Use todoGroupId as the primary identifier
text: decryptedText,
createdAt: row.createdAt,
completed: !!row.completed,
participants:
participants.rows.length > 0
? participants.rows.map((p) => p.userId)
: [userId], // Fallback to current user
createdBy: groupInfo.rows[0]?.createdBy || userId, // Fallback to current user
isCreator: (groupInfo.rows[0]?.createdBy || userId) === userId,
createdAt: todoData.createdAt,
completed: todoData.completed,
participants,
createdBy: groupInfo?.createdBy || userId,
isCreator: (groupInfo?.createdBy || userId) === userId,
});
} catch (e) {
console.error(`Error processing todo ${row.id}:`, e);
console.error(`Error processing todo ${todoGroupId}:`, e);
}
}
return todos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
async getTodo(userId, todoGroupId) {
const client = this.users.get(userId);
const row = await this.db.execute(
"SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?",
[todoGroupId, userId],
);
const todoKey = `${todoGroupId}_${userId}`;
const todoData = this.todos.get(todoKey);
if (row.rows.length === 0) throw new Error("Todo not found");
if (!todoData) throw new Error("Todo not found");
const todoRow = row.rows[0];
const decryptedText = client.decrypt(JSON.parse(todoRow.encrypted));
// Get all participants in this todo group
const participants = await this.db.execute(
"SELECT userId FROM todo_participants WHERE todoGroupId = ?",
[todoGroupId],
);
// Get group creator
const groupInfo = await this.db.execute(
"SELECT createdBy FROM todo_groups WHERE todoGroupId = ?",
[todoGroupId],
const decryptedText = client.decrypt(todoData.encrypted);
const groupInfo = this.todoGroups.get(todoGroupId);
const participants = Array.from(
this.todoParticipants.get(todoGroupId) || new Set(),
);
return {
id: todoGroupId,
text: decryptedText,
createdAt: todoRow.createdAt,
completed: !!todoRow.completed,
participants: participants.rows.map((p) => p.userId),
createdBy: groupInfo.rows[0]?.createdBy,
isCreator: groupInfo.rows[0]?.createdBy === userId,
createdAt: todoData.createdAt,
completed: todoData.completed,
participants,
createdBy: groupInfo?.createdBy,
isCreator: groupInfo?.createdBy === userId,
};
}
@ -313,26 +135,23 @@ export class EncryptedTodoService {
`updateTodoStatus called: userId=${userId}, todoGroupId=${todoGroupId}, completed=${completed}`,
);
// Get all participants in this todo group
const participants = await this.db.execute(
"SELECT userId FROM todo_participants WHERE todoGroupId = ?",
[todoGroupId],
);
const participants = this.todoParticipants.get(todoGroupId);
const participantIds = participants.rows.map((p) => p.userId);
if (!participantIds.includes(userId)) {
if (!participants || !participants.has(userId)) {
throw new Error("User is not a participant in this todo group");
}
const participantIds = Array.from(participants);
console.log(`Updating todo for all participants:`, participantIds);
// Update todos for all participants
for (const participantId of participantIds) {
await this.db.execute(
"UPDATE todos SET completed = ? WHERE todoGroupId = ? AND userId = ?",
[completed ? 1 : 0, todoGroupId, participantId],
);
const todoKey = `${todoGroupId}_${participantId}`;
const todoData = this.todos.get(todoKey);
if (todoData) {
todoData.completed = completed;
this.todos.set(todoKey, todoData);
}
}
console.log(`Affected users for todo update:`, participantIds);
@ -340,52 +159,43 @@ export class EncryptedTodoService {
}
async deleteTodo(userId, todoGroupId) {
// Get all participants in this todo group
const participants = await this.db.execute(
"SELECT userId FROM todo_participants WHERE todoGroupId = ?",
[todoGroupId],
);
const participants = this.todoParticipants.get(todoGroupId);
const participantIds = participants.rows.map((p) => p.userId);
if (!participantIds.includes(userId)) {
if (!participants || !participants.has(userId)) {
throw new Error("User is not a participant in this todo group");
}
// Check if user is the creator of this todo group
const groupInfo = await this.db.execute(
"SELECT createdBy FROM todo_groups WHERE todoGroupId = ?",
[todoGroupId],
);
const groupInfo = this.todoGroups.get(todoGroupId);
if (groupInfo.rows.length === 0) {
if (!groupInfo) {
throw new Error("Todo group not found");
}
if (groupInfo.rows[0].createdBy !== userId) {
if (groupInfo.createdBy !== userId) {
throw new Error("Only the creator can delete this todo");
}
const participantIds = Array.from(participants);
console.log(
`Deleting todo group ${todoGroupId} for all participants:`,
participantIds,
);
// Delete all todo entries for this group
await this.db.execute("DELETE FROM todos WHERE todoGroupId = ?", [
todoGroupId,
]);
for (const participantId of participantIds) {
const todoKey = `${todoGroupId}_${participantId}`;
this.todos.delete(todoKey);
// Delete all participants
await this.db.execute(
"DELETE FROM todo_participants WHERE todoGroupId = ?",
[todoGroupId],
);
// Remove from user's todo list
const userTodoSet = this.userTodos.get(participantId);
if (userTodoSet) {
userTodoSet.delete(todoGroupId);
}
}
// Delete the group itself
await this.db.execute("DELETE FROM todo_groups WHERE todoGroupId = ?", [
todoGroupId,
]);
// Delete participants and group
this.todoParticipants.delete(todoGroupId);
this.todoGroups.delete(todoGroupId);
return {
deletedIds: [todoGroupId],
@ -397,73 +207,61 @@ export class EncryptedTodoService {
// Ensure recipient user exists
if (!this.users.has(recipientId)) {
this.users.set(recipientId, SignalClient.create(recipientId));
this.userTodos.set(recipientId, new Set());
this.userIds.add(recipientId);
}
// Check if user is a participant in this todo group
const participants = await this.db.execute(
"SELECT userId FROM todo_participants WHERE todoGroupId = ?",
[todoGroupId],
);
const participants = this.todoParticipants.get(todoGroupId);
const participantIds = participants.rows.map((p) => p.userId);
if (!participantIds.includes(userId)) {
if (!participants || !participants.has(userId)) {
throw new Error("User is not a participant in this todo group");
}
// Check if user is the creator of this todo group
const groupInfo = await this.db.execute(
"SELECT createdBy FROM todo_groups WHERE todoGroupId = ?",
[todoGroupId],
);
const groupInfo = this.todoGroups.get(todoGroupId);
if (groupInfo.rows.length === 0) {
if (!groupInfo) {
throw new Error("Todo group not found");
}
if (groupInfo.rows[0].createdBy !== userId) {
if (groupInfo.createdBy !== userId) {
throw new Error("Only the creator can add participants to this todo");
}
if (participantIds.includes(recipientId)) {
if (participants.has(recipientId)) {
throw new Error("User is already a participant in this todo group");
}
// Get the original text from the sender's todo
const senderTodo = await this.db.execute(
"SELECT * FROM todos WHERE todoGroupId = ? AND userId = ?",
[todoGroupId, userId],
);
const senderTodoKey = `${todoGroupId}_${userId}`;
const senderTodoData = this.todos.get(senderTodoKey);
if (senderTodo.rows.length === 0) throw new Error("Todo not found");
if (!senderTodoData) throw new Error("Todo not found");
const senderTodoRow = senderTodo.rows[0];
const recipientClient = this.users.get(recipientId);
const encryptedForRecipient = recipientClient.encrypt(
senderTodoRow.originalText,
senderTodoData.originalText,
);
const recipientTodoId = uuidv4();
const now = new Date().toISOString();
// Add recipient as a participant
await this.db.execute(
"INSERT INTO todo_participants (todoGroupId, userId, joinedAt) VALUES (?, ?, ?)",
[todoGroupId, recipientId, now],
);
participants.add(recipientId);
this.todoParticipants.set(todoGroupId, participants);
// Create todo entry for the recipient
await this.db.execute(
"INSERT INTO todos (id, todoGroupId, userId, encrypted, createdAt, originalText, completed) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
recipientTodoId,
todoGroupId,
recipientId,
JSON.stringify(encryptedForRecipient),
now,
senderTodoRow.originalText,
senderTodoRow.completed,
],
);
const recipientTodoKey = `${todoGroupId}_${recipientId}`;
this.todos.set(recipientTodoKey, {
id: recipientTodoId,
todoGroupId,
userId: recipientId,
encrypted: encryptedForRecipient,
createdAt: now,
originalText: senderTodoData.originalText,
completed: senderTodoData.completed,
});
// Add to recipient's todo list
this.userTodos.get(recipientId).add(todoGroupId);
return todoGroupId;
}