From 94154d985c518cbd2e3376ecc3f24a5ee1119dd2 Mon Sep 17 00:00:00 2001 From: Johnathan Aspinwall Date: Sun, 22 Feb 2026 10:06:13 -0700 Subject: [PATCH 1/3] Implement multi-tenant user ID support for encrypted records and update database schema --- src/__tests__/crypto/database.test.js | 88 ++++++++++++ src/crypto/database.js | 127 ++++++++++++------ src/store/accounts/actions.js | 6 +- src/store/encryptedMiddleware.js | 117 ++++++++++------ .../recurringTransactionEvents/actions.js | 4 +- src/store/recurringTransactions/actions.js | 3 + src/store/statements/actions.js | 4 +- src/store/transactions/actions.js | 5 +- 8 files changed, 269 insertions(+), 85 deletions(-) create mode 100644 src/__tests__/crypto/database.test.js diff --git a/src/__tests__/crypto/database.test.js b/src/__tests__/crypto/database.test.js new file mode 100644 index 00000000..f01b2b8a --- /dev/null +++ b/src/__tests__/crypto/database.test.js @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { generateDataEncryptionKey } from '@/crypto/encryption'; +import { + db, + deleteEncryptedRecord, + getUserEncryptedRecords, + storeUserEncryptedRecord, +} from '@/crypto/database'; + +describe('Encrypted database user isolation', () => { + beforeEach(async () => { + await db.delete(); + await db.open(); + }); + + it('stores duplicate record ids for different users without conflict', async () => { + const userOneDek = await generateDataEncryptionKey(); + const userTwoDek = await generateDataEncryptionKey(); + + await storeUserEncryptedRecord( + 'transactions', + 'shared-id', + { id: 'shared-id', amount: 100 }, + userOneDek, + 'user-1', + ); + + await storeUserEncryptedRecord( + 'transactions', + 'shared-id', + { id: 'shared-id', amount: 200 }, + userTwoDek, + 'user-2', + ); + + const userOneRecords = await getUserEncryptedRecords( + 'transactions', + userOneDek, + 'user-1', + ); + const userTwoRecords = await getUserEncryptedRecords( + 'transactions', + userTwoDek, + 'user-2', + ); + + expect(userOneRecords).toEqual([{ id: 'shared-id', amount: 100 }]); + expect(userTwoRecords).toEqual([{ id: 'shared-id', amount: 200 }]); + }); + + it('deletes only the current user record when ids match', async () => { + const userOneDek = await generateDataEncryptionKey(); + const userTwoDek = await generateDataEncryptionKey(); + + await storeUserEncryptedRecord( + 'transactions', + 'shared-id', + { id: 'shared-id', amount: 100 }, + userOneDek, + 'user-1', + ); + + await storeUserEncryptedRecord( + 'transactions', + 'shared-id', + { id: 'shared-id', amount: 200 }, + userTwoDek, + 'user-2', + ); + + await deleteEncryptedRecord('transactions', 'shared-id', 'user-1'); + + const userOneRecords = await getUserEncryptedRecords( + 'transactions', + userOneDek, + 'user-1', + ); + const userTwoRecords = await getUserEncryptedRecords( + 'transactions', + userTwoDek, + 'user-2', + ); + + expect(userOneRecords).toEqual([]); + expect(userTwoRecords).toEqual([{ id: 'shared-id', amount: 200 }]); + }); +}); diff --git a/src/crypto/database.js b/src/crypto/database.js index 53ff6a05..0ba2cca5 100644 --- a/src/crypto/database.js +++ b/src/crypto/database.js @@ -15,26 +15,31 @@ import { } from './encryption'; const DB_NAME = 'LucaLedgerEncrypted'; -const DB_VERSION = 5; +const DB_VERSION = 6; + +const USER_SCOPED_STORES = [ + 'accounts', + 'transactions', + 'categories', + 'statements', + 'recurringTransactions', + 'recurringTransactionEvents', + 'transactionSplits', +]; // Create the database instance export const db = new Dexie(DB_NAME); -// Define schema with multi-user support -// Version 4 adds recurring transactions and occurrences -db.version(DB_VERSION).stores({ - users: 'id, username', // User table with unique usernames - accounts: 'id, userId', // Per-user accounts - transactions: 'id, userId', // Per-user transactions - categories: 'id, userId', // Per-user categories - statements: 'id, userId', // Per-user statements - recurringTransactions: 'id, userId', // Per-user recurring transactions - recurringTransactionEvents: 'id, userId', // Per-user recurring transaction events - transactionSplits: 'id, userId', // Per-user transaction splits - metadata: 'key', // Global key-value store for encryption metadata (legacy compatibility) +// Initial encrypted stores. +db.version(2).stores({ + accounts: 'id', + transactions: 'id', + categories: 'id', + statements: 'id', + metadata: 'key', }); -// Upgrade from version 2 to 3 - add userId to existing records +// Upgrade from version 2 to 3 - add user table and userId indexes. db.version(3).stores({ users: 'id, username', accounts: 'id, userId', @@ -44,15 +49,54 @@ db.version(3).stores({ metadata: 'key', }); -// Upgrade from version 2 to 3 - add userId to existing records -db.version(2).stores({ - accounts: 'id', - transactions: 'id', - categories: 'id', - statements: 'id', - metadata: 'key', +// Version 5 had per-user fields but primary key remained id-only. +db.version(5).stores({ + users: 'id, username', // User table with unique usernames + accounts: 'id, userId', // Per-user accounts + transactions: 'id, userId', // Per-user transactions + categories: 'id, userId', // Per-user categories + statements: 'id, userId', // Per-user statements + recurringTransactions: 'id, userId', // Per-user recurring transactions + recurringTransactionEvents: 'id, userId', // Per-user recurring transaction events + transactionSplits: 'id, userId', // Per-user transaction splits + metadata: 'key', // Global key-value store for encryption metadata (legacy compatibility) }); +// Version 6 uses composite primary keys to enforce tenant isolation. +db.version(DB_VERSION) + .stores({ + users: 'id, username', + accounts: '[userId+id], userId, id', + transactions: '[userId+id], userId, id', + categories: '[userId+id], userId, id', + statements: '[userId+id], userId, id', + recurringTransactions: '[userId+id], userId, id', + recurringTransactionEvents: '[userId+id], userId, id', + transactionSplits: '[userId+id], userId, id', + metadata: 'key', + }) + .upgrade(async (tx) => { + const users = await tx.table('users').toArray(); + const singleUserId = users.length === 1 ? users[0].id : null; + + await Promise.all( + USER_SCOPED_STORES.map(async (storeName) => { + const table = tx.table(storeName); + const records = await table.toArray(); + if (records.length === 0) return; + + await table.clear(); + await table.bulkPut( + records.map((record) => ({ + ...record, + // Deterministic assignment for legacy rows when only one user exists. + userId: record.userId || singleUserId || null, + })), + ); + }), + ); + }); + /** * Store encrypted record in database * @param {string} storeName - Name of the store (accounts, transactions) @@ -117,10 +161,16 @@ export async function getAllEncryptedRecords(storeName, dek) { * Delete a record from the database * @param {string} storeName - Name of the store * @param {string} id - Record ID + * @param {string} userId - User ID * @returns {Promise} */ -export async function deleteEncryptedRecord(storeName, id) { - await db[storeName].delete(id); +export async function deleteEncryptedRecord(storeName, id, userId) { + if (!userId) { + throw new Error( + `deleteEncryptedRecord requires userId for store "${storeName}"`, + ); + } + await db[storeName].delete([userId, id]); } /** @@ -285,10 +335,11 @@ export async function hasUsers() { */ export async function deleteUser(userId) { // Delete all user-specific data - await db.accounts.where('userId').equals(userId).delete(); - await db.transactions.where('userId').equals(userId).delete(); - await db.categories.where('userId').equals(userId).delete(); - await db.statements.where('userId').equals(userId).delete(); + await Promise.all( + USER_SCOPED_STORES.map((storeName) => + db[storeName].where('userId').equals(userId).delete(), + ), + ); // Delete the user record await db.users.delete(userId); @@ -380,11 +431,7 @@ export async function batchStoreUserEncryptedRecords( * @returns {Promise} */ export async function deleteUserEncryptedRecord(storeName, id, userId) { - // First verify the record belongs to the user - const record = await db[storeName].get(id); - if (record && record.userId === userId) { - await db[storeName].delete(id); - } + await db[storeName].delete([userId, id]); } /** @@ -393,10 +440,11 @@ export async function deleteUserEncryptedRecord(storeName, id, userId) { * @returns {Promise} */ export async function clearUserData(userId) { - await db.accounts.where('userId').equals(userId).delete(); - await db.transactions.where('userId').equals(userId).delete(); - await db.categories.where('userId').equals(userId).delete(); - await db.statements.where('userId').equals(userId).delete(); + await Promise.all( + USER_SCOPED_STORES.map((storeName) => + db[storeName].where('userId').equals(userId).delete(), + ), + ); } /** @@ -421,12 +469,11 @@ export async function hasLegacyEncryptedData() { */ export async function migrateLegacyDataToUser(userId) { // Update all records without userId to have the specified userId - const stores = ['accounts', 'transactions', 'categories', 'statements']; - - for (const storeName of stores) { + for (const storeName of USER_SCOPED_STORES) { const records = await db[storeName].filter((r) => !r.userId).toArray(); for (const record of records) { - await db[storeName].update(record.id, { userId }); + await db[storeName].delete([record.userId || null, record.id]); + await db[storeName].put({ ...record, userId }); } } } diff --git a/src/store/accounts/actions.js b/src/store/accounts/actions.js index 8f388d1d..1a472dbf 100644 --- a/src/store/accounts/actions.js +++ b/src/store/accounts/actions.js @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid'; import { SCHEMA_VERSION } from '@luca-financial/luca-schema'; import { deleteEncryptedRecord } from '@/crypto/database'; +import { getCurrentUserForMiddleware } from '@/store/encryptedMiddleware'; import { selectors as accountSelectors } from '@/store/accounts'; import { setCategories } from '@/store/categories'; @@ -195,14 +196,15 @@ export const removeAccountById = (id) => async (dispatch, getState) => { // Handle encrypted data if enabled const isEncrypted = state.encryption?.status === 'encrypted'; if (isEncrypted) { + const { userId } = getCurrentUserForMiddleware(); try { // Delete account from encrypted database - await deleteEncryptedRecord('accounts', id); + await deleteEncryptedRecord('accounts', id, userId); // Delete all related transactions from encrypted database await Promise.all( transactions.map((transaction) => - deleteEncryptedRecord('transactions', transaction.id), + deleteEncryptedRecord('transactions', transaction.id, userId), ), ); } catch (error) { diff --git a/src/store/encryptedMiddleware.js b/src/store/encryptedMiddleware.js index 19c991c8..07f8407f 100644 --- a/src/store/encryptedMiddleware.js +++ b/src/store/encryptedMiddleware.js @@ -7,7 +7,7 @@ import { storeUserEncryptedRecord, batchStoreUserEncryptedRecords, - db, + deleteUserEncryptedRecord, } from '@/crypto/database'; import { EncryptionStatus } from './encryption'; @@ -37,27 +37,27 @@ export function clearCurrentUserFromMiddleware() { currentDEK = null; } +export function getCurrentUserForMiddleware() { + return { + userId: currentUserId, + dek: currentDEK, + }; +} + /** * Flush the write queue to IndexedDB */ async function flushWriteQueue() { if (writeQueue.length === 0) return; - if (!currentDEK || !currentUserId) return; - const queue = [...writeQueue]; writeQueue = []; try { // Process all queued writes - for (const { storeName, id, data } of queue) { - await storeUserEncryptedRecord( - storeName, - id, - data, - currentDEK, - currentUserId, - ); + for (const { storeName, id, data, userId, dek } of queue) { + if (!userId || !dek) continue; + await storeUserEncryptedRecord(storeName, id, data, dek, userId); } } catch (error) { console.error('Failed to persist encrypted data:', error); @@ -68,15 +68,32 @@ async function flushWriteQueue() { * Queue a write to IndexedDB (throttled) */ function queueWrite(storeName, id, data) { + if (!currentUserId || !currentDEK) return; + // Add or update in queue const existingIndex = writeQueue.findIndex( - (item) => item.storeName === storeName && item.id === id, + (item) => + item.storeName === storeName && + item.id === id && + item.userId === currentUserId, ); if (existingIndex >= 0) { - writeQueue[existingIndex] = { storeName, id, data }; + writeQueue[existingIndex] = { + storeName, + id, + data, + userId: currentUserId, + dek: currentDEK, + }; } else { - writeQueue.push({ storeName, id, data }); + writeQueue.push({ + storeName, + id, + data, + userId: currentUserId, + dek: currentDEK, + }); } // Reset the timeout @@ -140,9 +157,11 @@ function handleEncryptedPersistence(action, state) { } else if (action.type === 'accounts/removeAccount') { // Delete from IndexedDB immediately const accountId = action.payload; - db.accounts.delete(accountId).catch((error) => { - console.error('Failed to delete account from IndexedDB:', error); - }); + deleteUserEncryptedRecord('accounts', accountId, currentUserId).catch( + (error) => { + console.error('Failed to delete account from IndexedDB:', error); + }, + ); } // Handle transaction actions @@ -178,7 +197,11 @@ function handleEncryptedPersistence(action, state) { } else if (action.type === 'transactions/removeTransaction') { // Delete from IndexedDB immediately const transactionId = action.payload; - db.transactions.delete(transactionId).catch((error) => { + deleteUserEncryptedRecord( + 'transactions', + transactionId, + currentUserId, + ).catch((error) => { console.error('Failed to delete transaction from IndexedDB:', error); }); } @@ -192,17 +215,21 @@ function handleEncryptedPersistence(action, state) { // Delete from IndexedDB immediately (category and all its children) const categoryId = action.payload; // Delete the category itself - db.categories.delete(categoryId).catch((error) => { - console.error('Failed to delete category from IndexedDB:', error); - }); + deleteUserEncryptedRecord('categories', categoryId, currentUserId).catch( + (error) => { + console.error('Failed to delete category from IndexedDB:', error); + }, + ); // Delete all subcategories (children) of this category const children = state.categories.filter( (cat) => cat.parentId === categoryId, ); children.forEach((child) => { - db.categories.delete(child.id).catch((error) => { - console.error('Failed to delete subcategory from IndexedDB:', error); - }); + deleteUserEncryptedRecord('categories', child.id, currentUserId).catch( + (error) => { + console.error('Failed to delete subcategory from IndexedDB:', error); + }, + ); }); } else if (action.type === 'categories/setCategories') { // When setting all categories, persist all to IndexedDB @@ -257,9 +284,11 @@ function handleEncryptedPersistence(action, state) { } else if (action.type === 'statements/removeStatement') { // Delete from IndexedDB immediately const statementId = action.payload; - db.statements.delete(statementId).catch((error) => { - console.error('Failed to delete statement from IndexedDB:', error); - }); + deleteUserEncryptedRecord('statements', statementId, currentUserId).catch( + (error) => { + console.error('Failed to delete statement from IndexedDB:', error); + }, + ); } // Handle recurring transaction actions @@ -291,12 +320,14 @@ function handleEncryptedPersistence(action, state) { action.type === 'recurringTransactions/removeRecurringTransaction' ) { const id = action.payload; - db.recurringTransactions.delete(id).catch((error) => { - console.error( - 'Failed to delete recurring transaction from IndexedDB:', - error, - ); - }); + deleteUserEncryptedRecord('recurringTransactions', id, currentUserId).catch( + (error) => { + console.error( + 'Failed to delete recurring transaction from IndexedDB:', + error, + ); + }, + ); } // Handle recurring transaction event actions @@ -328,7 +359,11 @@ function handleEncryptedPersistence(action, state) { action.type === 'recurringTransactionEvents/removeRecurringTransactionEvent' ) { const id = action.payload; - db.recurringTransactionEvents.delete(id).catch((error) => { + deleteUserEncryptedRecord( + 'recurringTransactionEvents', + id, + currentUserId, + ).catch((error) => { console.error( 'Failed to delete recurring transaction event from IndexedDB:', error, @@ -368,11 +403,13 @@ function handleEncryptedPersistence(action, state) { }); } else if (action.type === 'transactionSplits/removeTransactionSplit') { const id = action.payload; - db.transactionSplits.delete(id).catch((error) => { - console.error( - 'Failed to delete transaction split from IndexedDB:', - error, - ); - }); + deleteUserEncryptedRecord('transactionSplits', id, currentUserId).catch( + (error) => { + console.error( + 'Failed to delete transaction split from IndexedDB:', + error, + ); + }, + ); } } diff --git a/src/store/recurringTransactionEvents/actions.js b/src/store/recurringTransactionEvents/actions.js index e5f23e01..0373e6a5 100644 --- a/src/store/recurringTransactionEvents/actions.js +++ b/src/store/recurringTransactionEvents/actions.js @@ -1,4 +1,5 @@ import { deleteEncryptedRecord } from '@/crypto/database'; +import { getCurrentUserForMiddleware } from '@/store/encryptedMiddleware'; import { TransactionStateEnum } from '@/store/transactions/constants'; import { generateTransaction } from '@/store/transactions/generators'; import { addTransaction } from '@/store/transactions/slice'; @@ -47,8 +48,9 @@ export const removeRecurringTransactionEventById = const state = getState(); const isEncrypted = state.encryption?.status === 'encrypted'; if (isEncrypted) { + const { userId } = getCurrentUserForMiddleware(); try { - await deleteEncryptedRecord('recurringTransactionEvents', eventId); + await deleteEncryptedRecord('recurringTransactionEvents', eventId, userId); } catch (error) { console.error('Failed to delete encrypted recurring event:', error); throw error; diff --git a/src/store/recurringTransactions/actions.js b/src/store/recurringTransactions/actions.js index f4a0a2ba..b188ecca 100644 --- a/src/store/recurringTransactions/actions.js +++ b/src/store/recurringTransactions/actions.js @@ -1,4 +1,5 @@ import { deleteEncryptedRecord } from '@/crypto/database'; +import { getCurrentUserForMiddleware } from '@/store/encryptedMiddleware'; import { generateRecurringTransaction } from './generators'; import { addRecurringTransaction, @@ -57,10 +58,12 @@ export const removeRecurringTransactionById = // Handle encrypted data if enabled const isEncrypted = state.encryption?.status === 'encrypted'; if (isEncrypted) { + const { userId } = getCurrentUserForMiddleware(); try { await deleteEncryptedRecord( 'recurringTransactions', recurringTransactionId, + userId, ); } catch (error) { console.error( diff --git a/src/store/statements/actions.js b/src/store/statements/actions.js index b3178919..dfcbaeda 100644 --- a/src/store/statements/actions.js +++ b/src/store/statements/actions.js @@ -14,6 +14,7 @@ import { } from './utils'; import { addDays, subDays, parseISO, format } from 'date-fns'; import { deleteEncryptedRecord } from '@/crypto/database'; +import { getCurrentUserForMiddleware } from '@/store/encryptedMiddleware'; const parseDateSafe = (dateStr) => { if (!dateStr) return null; @@ -253,8 +254,9 @@ export const removeStatementById = // Handle encrypted data if enabled const isEncrypted = state.encryption?.status === 'encrypted'; if (isEncrypted) { + const { userId } = getCurrentUserForMiddleware(); try { - await deleteEncryptedRecord('statements', statementId); + await deleteEncryptedRecord('statements', statementId, userId); } catch (error) { console.error('Failed to delete encrypted statement:', error); throw error; diff --git a/src/store/transactions/actions.js b/src/store/transactions/actions.js index ad5ca1de..271dde3e 100644 --- a/src/store/transactions/actions.js +++ b/src/store/transactions/actions.js @@ -12,6 +12,7 @@ import { import config from '@/config'; import { deleteEncryptedRecord } from '@/crypto/database'; +import { getCurrentUserForMiddleware } from '@/store/encryptedMiddleware'; import { removeRecurringTransactionEvent } from '@/store/recurringTransactionEvents/slice'; import { generateTransaction } from './generators'; import { @@ -123,14 +124,16 @@ export const removeTransactionById = // Handle encrypted data if enabled const isEncrypted = state.encryption?.status === 'encrypted'; if (isEncrypted) { + const { userId } = getCurrentUserForMiddleware(); try { - await deleteEncryptedRecord('transactions', transaction.id); + await deleteEncryptedRecord('transactions', transaction.id, userId); // Also delete the linked recurring transaction event if it exists if (recurringEvent) { await deleteEncryptedRecord( 'recurringTransactionEvents', recurringEvent.id, + userId, ); } } catch (error) { From 36b0db93a28218f9ede8aba6c185666ad39003af Mon Sep 17 00:00:00 2001 From: Johnathan Aspinwall Date: Sun, 22 Feb 2026 21:57:46 -0700 Subject: [PATCH 2/3] Enforce unique usernames at the database level and update database version to 8 --- src/__tests__/crypto/database.test.js | 8 ++++ src/crypto/database.js | 62 +++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/__tests__/crypto/database.test.js b/src/__tests__/crypto/database.test.js index f01b2b8a..8679f528 100644 --- a/src/__tests__/crypto/database.test.js +++ b/src/__tests__/crypto/database.test.js @@ -85,4 +85,12 @@ describe('Encrypted database user isolation', () => { expect(userOneRecords).toEqual([]); expect(userTwoRecords).toEqual([{ id: 'shared-id', amount: 200 }]); }); + + it('enforces unique usernames at the database level', async () => { + await db.users.add({ id: 'user-1', username: 'alex' }); + + await expect( + db.users.add({ id: 'user-2', username: 'alex' }), + ).rejects.toThrow(); + }); }); diff --git a/src/crypto/database.js b/src/crypto/database.js index 0ba2cca5..08b262b3 100644 --- a/src/crypto/database.js +++ b/src/crypto/database.js @@ -15,7 +15,7 @@ import { } from './encryption'; const DB_NAME = 'LucaLedgerEncrypted'; -const DB_VERSION = 6; +const DB_VERSION = 8; const USER_SCOPED_STORES = [ 'accounts', @@ -51,7 +51,7 @@ db.version(3).stores({ // Version 5 had per-user fields but primary key remained id-only. db.version(5).stores({ - users: 'id, username', // User table with unique usernames + users: 'id, username', // User table with username lookup index accounts: 'id, userId', // Per-user accounts transactions: 'id, userId', // Per-user transactions categories: 'id, userId', // Per-user categories @@ -62,8 +62,9 @@ db.version(5).stores({ metadata: 'key', // Global key-value store for encryption metadata (legacy compatibility) }); -// Version 6 uses composite primary keys to enforce tenant isolation. -db.version(DB_VERSION) +// Version 7 keeps composite tenant keys and deduplicates usernames before +// adding a DB-level unique username index. +db.version(7) .stores({ users: 'id, username', accounts: '[userId+id], userId, id', @@ -75,6 +76,59 @@ db.version(DB_VERSION) transactionSplits: '[userId+id], userId, id', metadata: 'key', }) + .upgrade(async (tx) => { + const usersTable = tx.table('users'); + const users = await usersTable.toArray(); + if (users.length <= 1) return; + + const usersByUsername = new Map(); + for (const user of users) { + const username = user.username; + if (!username) continue; + + const existing = usersByUsername.get(username); + if (!existing) { + usersByUsername.set(username, user); + continue; + } + + const existingCreatedAt = existing.createdAt || ''; + const currentCreatedAt = user.createdAt || ''; + const shouldReplace = + currentCreatedAt < existingCreatedAt || + (currentCreatedAt === existingCreatedAt && user.id < existing.id); + + if (shouldReplace) { + usersByUsername.set(username, user); + } + } + + const usernamesToKeep = new Set( + Array.from(usersByUsername.values()).map((user) => user.id), + ); + + const duplicateIds = users + .filter((user) => user.username && !usernamesToKeep.has(user.id)) + .map((user) => user.id); + + if (duplicateIds.length > 0) { + await usersTable.bulkDelete(duplicateIds); + } + }); + +// Version 8 enforces DB-level uniqueness for usernames. +db.version(DB_VERSION) + .stores({ + users: 'id, &username', + accounts: '[userId+id], userId, id', + transactions: '[userId+id], userId, id', + categories: '[userId+id], userId, id', + statements: '[userId+id], userId, id', + recurringTransactions: '[userId+id], userId, id', + recurringTransactionEvents: '[userId+id], userId, id', + transactionSplits: '[userId+id], userId, id', + metadata: 'key', + }) .upgrade(async (tx) => { const users = await tx.table('users').toArray(); const singleUserId = users.length === 1 ? users[0].id : null; From 2a193dda6070f4cd49b56e65f075a4ed9e9b22d1 Mon Sep 17 00:00:00 2001 From: Johnathan Aspinwall Date: Thu, 12 Mar 2026 17:18:31 -0600 Subject: [PATCH 3/3] Add .claude to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b02a1ff7..786199bf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ package-lock.json *.njsproj *.sln *.sw? + +.claude