Compare commits

..
10 Commits
Author SHA1 Message Date
github-actions[bot] 760235e667 chore: bump version to 0.28.4 2026-06-09 19:10:42 +00:00
Claude 0cc64fd8f3 fix(deps): patch high/critical security advisories
Bumps runtime- and build-reachable dependencies to patched versions and
adds pnpm overrides for transitive advisories. Clears all high/critical
findings (1 critical + 12 high -> 0); remaining moderates/lows are
dev-tooling only and stripped from the production image by pnpm prune.

Direct bumps:
- drizzle-orm ^0.44.7 -> ^0.45.2  (SQL injection via unescaped identifiers, GHSA-gpj5-g38j-94v9)
- @sveltejs/kit ^2.53.4 -> ^2.63.1 (adapter-node BODY_SIZE_LIMIT bypass, GHSA-2crg-3p73-43xp)
- vite ^7.3.1 -> ^7.3.5, vitest/@vitest/coverage-v8 ^3.2.4 -> ^3.2.6 (dev-server file read; vitest UI RCE)

Transitive overrides:
- devalue >=5.8.1   (DoS via sparse-array deserialization, GHSA-77vg-94rm-hx3p)
- path-to-regexp@8 >=8.4.0 (ReDoS via express/router under @modelcontextprotocol/sdk, GHSA-j3q9-mxjg-w52f)
- fast-uri >=3.1.2  (path traversal / host confusion via ajv, GHSA-q3j6-qgpj-74h6)

Verified: pnpm check (0 errors), 264 unit tests pass, production build succeeds.
2026-06-09 21:08:39 +02:00
Claude dbade3595e ci: run on push to main only to avoid duplicate PR runs
A push to a claude/** branch with an open PR triggered both the push
and pull_request events. The concurrency group keys on github.ref,
which differs between the two (refs/heads/... vs refs/pull/N/merge),
so neither run was cancelled and the whole pipeline (including E2E)
ran twice. Drop claude/** from the push trigger; branch work is now
validated once via the pull_request event.
2026-06-09 20:58:58 +02:00
Claude 585be91be5 chore: pin pnpm via packageManager field for CI and tooling
Adds a packageManager field (pnpm@9.15.9) to package.json so corepack
and pnpm/action-setup@v4 resolve a single pinned pnpm 9 version that
matches lockfileVersion 9.0. Removes the floating 'version: latest' from
the CI and screenshots workflows so they honor the packageManager field
instead of pulling pnpm 10+, which breaks native module builds.

Follow-up to #63.
2026-06-09 20:54:29 +02:00
Romain LE DISEZandGoogle Gemini c2bb6ef75a fix(docker): pin pnpm version to 9 to match lockfile and fix build errors
Co-authored-by: Google Gemini <gemini@google.com>
2026-06-09 20:50:22 +02:00
github-actions[bot] 2fb1237f04 chore: pin docker-compose to v0.28.3 [skip ci] 2026-04-22 19:52:23 +00:00
github-actions[bot] 808217eb55 chore: bump version to 0.28.3 2026-04-22 19:46:17 +00:00
Claude ea403dcbee test: upload a real attachment in the user-deletion coverage test
The "notes, tags, and attachments" scenario previously only created a
note and an API key — no attachment. Upload a PNG via the browser
context so Origin is set correctly for SvelteKit's CSRF check, giving
the atomic-delete path genuine attachment rows to clean up.

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
Claude 5719fc3459 fix: make admin user deletion atomic and surface errors in UI
deleteUser() ran each child-table cleanup as a separate statement, so a
foreign-key violation on any step (e.g. sync_log rows from other users
referencing the deleted user's notes) would abort mid-way and leave the
database in a partial state. The API swallowed the error and the admin
page silently ignored the non-ok response, so clicking Delete and
confirming appeared to do nothing.

- Wrap the full cascade in a single sqlite transaction so the deletion
  is atomic.
- Also remove rows in sync_log, note_user_state, note_collaborators,
  note_versions, and attachments that belong to other users but
  reference the deleted user's notes, so the final DELETE FROM notes
  cannot trip a foreign-key check.
- Surface delete failures in the users settings UI instead of silently
  swallowing them.
- Add data-testids on the user row and delete button to support tests.
- Add e2e coverage for the admin user deletion flow (happy path,
  cancelled confirmation, and a user with notes / tags / attachments /
  preferences / api keys).

https://claude.ai/code/session_015T2HE6VzB64ZmSSyy1Wz4R
2026-04-22 21:45:48 +02:00
github-actions[bot] b2e7b2c96d chore: pin docker-compose to v0.28.2 [skip ci] 2026-04-10 20:31:53 +00:00
11 changed files with 1775 additions and 1049 deletions
+1 -7
View File
@@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main, 'claude/**']
branches: [main]
pull_request:
branches: [main]
workflow_call:
@@ -19,8 +19,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -37,8 +35,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
@@ -68,8 +64,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
-2
View File
@@ -14,8 +14,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
+1 -1
View File
@@ -146,7 +146,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
GitHub Actions with two workflows:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
1. **Lint & Type Check** — `pnpm check`
2. **Unit Tests** — `pnpm test:unit`
3. **Build** — `pnpm build` (depends on steps 1+2)
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.28.1
image: ghcr.io/bretzel-app/crumbs:0.28.3
container_name: crumbs
ports:
- "3000:3000"
+11 -7
View File
@@ -1,8 +1,9 @@
{
"name": "crumbs",
"private": true,
"version": "0.28.2",
"version": "0.28.4",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
@@ -51,7 +52,7 @@
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"drizzle-orm": "^0.45.2",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
@@ -66,7 +67,7 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
@@ -76,7 +77,7 @@
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^3.2.6",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"nyc": "^18.0.0",
@@ -85,10 +86,10 @@
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^7.3.5",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"vitest": "^3.2.6",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
@@ -102,7 +103,10 @@
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
"hono": ">=4.12.7",
"devalue": ">=5.8.1",
"path-to-regexp@8": ">=8.4.0",
"fast-uri": ">=3.1.2"
}
}
}
+1546 -1004
View File
File diff suppressed because it is too large Load Diff
+47 -23
View File
@@ -227,29 +227,53 @@ export function getUser(userId: number): User | null {
}
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
sqlite
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
.run(userId, userId);
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
// Atomic cascade: all-or-nothing. Also clear rows from other users that
// reference the deleted user's notes (e.g. sync_log, note_user_state,
// note_collaborators) so foreign-key checks don't block the final delete.
const tx = sqlite.transaction((uid: number) => {
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
sqlite
.prepare(
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid, uid);
sqlite
.prepare(
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid, uid);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite
.prepare(
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(uid);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
tx(userId);
}
export async function changePassword(
+13 -2
View File
@@ -70,9 +70,16 @@
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
createMsg = 'User deleted';
createError = false;
} else {
const d = await res.json().catch(() => ({}));
createMsg = d.message || 'Failed to delete user';
createError = true;
}
} catch {
// ignore
createMsg = 'Connection error';
createError = true;
}
}
@@ -167,7 +174,10 @@
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
<div
class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between"
data-testid="user-row-{user.id}"
>
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
@@ -195,6 +205,7 @@
</button>
<button
onclick={() => deleteUser(user.id)}
data-testid="delete-user-btn-{user.id}"
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
+153
View File
@@ -0,0 +1,153 @@
import { test, expect } from './helpers/fixtures.js';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { TEST_CREDENTIALS_FILE } from './global-setup';
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
/** Create a victim user via the admin API, returning the user's id. */
async function createVictim(
page: import('@playwright/test').Page,
email: string,
displayName = email.split('@')[0]
): Promise<number> {
const res = await page.request.post('/api/admin/users', {
data: { email, displayName, password: userPassword, role: 'user' }
});
expect(res.status()).toBe(201);
const user = await res.json();
return user.id;
}
test.describe.serial('Admin — User Deletion', () => {
test('Scenario: Admin deletes a user from the users list', async ({
authenticatedPage: page
}) => {
// Given a disposable user exists
const victimId = await createVictim(page, 'delete-me@test.com', 'Delete Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And accepts the confirmation dialog for deletion
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete request succeeds
expect(res.status()).toBe(200);
// And the user disappears from the list
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the list is reloaded correctly on refresh (backend is consistent)
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
});
test('Scenario: Deletion is cancelled when the admin dismisses the confirmation', async ({
authenticatedPage: page
}) => {
// Given a user exists
const victimId = await createVictim(page, 'keep-me@test.com', 'Keep Me');
// When the admin navigates to the users settings page
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// And dismisses the confirmation dialog
page.once('dialog', (dialog) => dialog.dismiss());
await page.getByTestId(`delete-user-btn-${victimId}`).click();
// Then the user remains in the list
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
// Clean up
await page.request.delete(`/api/admin/users/${victimId}`);
});
test('Scenario: Deleting a user with notes, tags, and attachments succeeds', async ({
authenticatedPage: page,
browser
}) => {
// Given a user with full data exists (note, tag, attachment, preferences, api key)
const email = 'heavy-user@test.com';
const victimId = await createVictim(page, email, 'Heavy User');
// Log in as the victim in a fresh context so they create their own data
const ctx = await browser.newContext();
const victimPage = await ctx.newPage();
await victimPage.goto('/login');
await victimPage.getByTestId('email-input').fill(email);
await victimPage.getByTestId('password-input').fill(userPassword);
await victimPage.getByTestId('login-btn').click();
await victimPage.waitForURL('/');
// Create a note with a tag
const noteRes = await victimPage.request.post('/api/notes', {
data: { title: 'Victim Note #todo', content: 'content #work' }
});
expect(noteRes.ok()).toBe(true);
const note = await noteRes.json();
// Upload an attachment on the note. Use a browser-context fetch so the
// Origin header is set correctly for SvelteKit's CSRF check.
const imageBase64 = readFileSync(TEST_IMAGE_PATH).toString('base64');
const attachmentStatus = await victimPage.evaluate(
async ({ noteId, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/png' }), 'test-image.png');
const r = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: form
});
return r.status;
},
{ noteId: note.id, base64: imageBase64 }
);
expect(attachmentStatus).toBe(201);
// Write a preference and create an API key to exercise every table
await victimPage.request.put('/api/preferences', {
data: { defaultNoteMode: 'markdown' }
});
await victimPage.request.post('/api/settings/api-keys', { data: { name: 'test-key' } });
await ctx.close();
// When the admin deletes the user with all their data
await page.goto('/settings/users');
await page.waitForLoadState('networkidle');
await expect(page.getByTestId(`user-row-${victimId}`)).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
const deleteResponse = page.waitForResponse(
(res) => res.url().endsWith(`/api/admin/users/${victimId}`) && res.request().method() === 'DELETE'
);
await page.getByTestId(`delete-user-btn-${victimId}`).click();
const res = await deleteResponse;
// Then the delete succeeds atomically (no partial state)
expect(res.status()).toBe(200);
await expect(page.getByTestId(`user-row-${victimId}`)).toHaveCount(0);
// And the user cannot log in afterwards
const loginRes = await page.request.post('/api/auth/login', {
data: { email, password: userPassword }
});
expect(loginRes.status()).toBe(401);
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.28.2</span>
<span class="footer-version">v0.28.4</span>
</div>
</footer>