diff --git a/.env.example b/.env.example index 76107c8..87001d1 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DBNAME?sslmode=require # Optional. CA certificate used to verify the Postgres server's TLS identity — # either the PEM contents inline or a path to a .pem/.crt file. When set, the # connection verifies the server (rejectUnauthorized: true); when unset, the -# wire is encrypted but the server identity is not verified (fine for a +# connection uses TLS but the server identity is not verified (fine for a # self-signed Postgres on the same host, weaker over an untrusted network). DATABASE_CA_CERT= @@ -32,9 +32,6 @@ ANTHROPIC_MODEL= RESEND_API_KEY= EMAIL_FROM="Keep " -# Passkeys (WebAuthn): pin to your domain so credentials survive across preview -# URLs; falls back to the request host for local dev. -PASSKEY_RP_ID= # Canonical app URL, used for auth callbacks and verification links. AUTH_URL=https://your-domain diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad380b1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Keep contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 12342a6..ca3c28e 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,98 @@ # Keep -A small, opinionated notes app — autosave, pin/archive/trash, full-text search across title and body, and a date-grouped ChatGPT-style sidebar. Built as a single-page Next.js app backed by Postgres, with a guest mode that stores notes in `localStorage` until you sign in. +A small, opinionated notes app with autosave, pin/archive/trash, full-text +search, and a date-grouped sidebar. It is a Next.js application backed by +Postgres, with a guest mode that keeps notes in the browser until sign-in. ![Keep — sidebar with date-grouped notes and an open note editor](./docs/screenshot.png) ## Stack -- **Next.js 14** (App Router, route handlers for the API) +- **Next.js 16** App Router and React 19 - **TypeScript** end to end -- **Tailwind CSS** with CSS variables for the graphite & amber color tokens (dark + light) -- **NextAuth** for sign-in (guest mode works without auth) -- **Postgres** via `pg`, with a pooled client cached across hot reloads and a self-healing schema bootstrap +- **Tailwind CSS** with app-wide color tokens +- **Auth.js / NextAuth** with Google and verified email/password sign-in +- **Postgres** through a small pooled data layer +- **IndexedDB** for account-scoped note caching and queued mutations +- Native **iOS and macOS** clients sharing the same authenticated API ## Features -- Autosaving editor — no Save button; new notes persist as soon as you start typing -- LLM-generated note titles (falls back to local inference when no API key is set) -- Pin, archive, move-to-trash, restore, delete-forever -- Tags with one-click filter chips in the sidebar -- Full-text search via a `⌘K` / `/` / `f` modal with `↑` `↓` and `Enter` navigation -- Keyboard-first: `j`/`k` note navigation, single-key actions, and a `?` shortcuts overlay -- Date-grouped sidebar (Today / Yesterday / Previous 7 days / Previous 30 days / Older) -- Per-note URLs — opening a note rewrites to `/`, so refresh and bookmarks land back on it -- Offline support — a service worker plus an IndexedDB outbox that queues edits and replays them on reconnect -- Archive and Trash reachable from the Settings pane with a one-click "Back to notes" -- Guest mode: notes live in `localStorage`; signing in migrates them to your account -- Markdown preview and opt-in Shiki syntax highlighting per note -- Version history with line diffs and one-click restore -- Public share links (`/p/`) with revocation -- Sign in with Google or email + password -- Google Keep Takeout import (Takeout ZIP or single `.json`) -- Plain-text export — single `.txt` per note, or a ZIP of everything -- Graceful DB-error banner with retry — the app stays usable even when Postgres is unreachable -- SSL-aware Postgres connection that works against both self-hosted (self-signed cert) and managed providers +- Debounced autosave for new and existing notes +- LLM-generated titles and summaries through Anthropic, with a local fallback +- Pin, archive, trash, restore, and delete forever +- Tags, color labels, and full-text search across title and body +- Keyboard navigation and a searchable command-style overlay +- Stable deep links at `/note/` +- Guest notes in `localStorage`, with idempotent migration after sign-in +- Account-scoped offline cache and ordered mutation replay for interrupted saves +- Markdown preview and optional Shiki syntax highlighting, persisted per note +- Public share links with 128-bit bearer tokens, vanity links, and revocation +- Google Keep Takeout, plain-text, Markdown, ZIP, and PDF import +- Plain-text or ZIP export +- Public image uploads through S3-compatible storage or Vercel Blob +- Anonymous aggregate analytics and a private owner dashboard + +Offline support protects edits made while the application is already open. +For privacy, personalized HTML and public shared notes are never stored by the +service worker, so a full page reload still requires a network connection. ## Getting started ```bash -# 1. Install npm install - -# 2. Configure cp .env.example .env.local -# set AUTH_SECRET (openssl rand -base64 32) and DATABASE_URL -# (plus OPENAI_API_KEY if you want LLM titles) - -# 3. Run +# Set AUTH_SECRET and DATABASE_URL. +# Add Google, Resend, Anthropic, and object-storage settings as needed. npm run dev ``` -The schema (one `notes` table + a few indexes) is created on first request — no separate migration step. Migrations are expressed idempotently in `lib/db.ts` so dropped columns and added indexes apply on next boot. +The idempotent bootstrap in `lib/db.ts` creates the notes, users, native-auth, +audit, and analytics tables on first use. A transient bootstrap failure is +retryable on the next request. -`npm test` runs the Vitest suite covering title inference, Takeout import parsing, and the autosave debounce. +Useful checks: + +```bash +npm test +npx tsc --noEmit +npm run build +npm audit +``` ## Project layout -``` +```text app/ - api/notes/ CRUD + import + export + title route handlers - [noteId]/ Deep links back into an open note - p/[token]/ Public share pages - layout.tsx Root layout - page.tsx Header + NotesView - signin/ NextAuth sign-in page + api/notes/ Authenticated CRUD, import/export, titles, sharing + api/auth/ Auth.js plus registration and email verification + note/[noteId]/ Stable note deep links + p/[token]/ Public shared-note pages components/ - Header.tsx Top bar (wordmark + auth chip) - NotesView.tsx Top-level state + editor wiring - Sidebar.tsx Date-grouped note list + tag filter chips - NoteEditor.tsx Autosaving editor (new + edit modes both autosave) - SearchOverlay.tsx ⌘K full-text search modal - SettingsPane.tsx Views (Archive/Trash) + Data (import/export) sections - ShortcutsOverlay.tsx `?` keyboard reference - ... Markdown preview, Shiki editor, theme toggle + NotesView.tsx Top-level product state and editor wiring + NoteEditor.tsx Race-safe debounced editor + Sidebar.tsx Date groups, filters, actions, and sharing + NotesGrid.tsx Responsive note cards lib/ - db.ts pg Pool + SSL handling + idempotent schema bootstrap - types.ts Note shape - useNotes.ts Client hook: fetch + optimistic mutations + guest sync - offlineDb.ts IndexedDB note cache + pending-op outbox - inferTitle.ts Local zero-token title fallback - titleModel.ts OpenAI-backed title generation - googleKeepImport.ts Takeout parser - noteExport.ts Plain-text / ZIP export -__tests__/ Vitest: title inference, Takeout import, autosave -auth.ts NextAuth config + db.ts Postgres pool and idempotent schema bootstrap + useNotes.ts Optimistic CRUD and ordered sync queue + offlineDb.ts Per-account IndexedDB cache and outbox + titleModel.ts Anthropic metadata with local fallback +ios/ Native iOS and macOS clients +__tests__/ Unit, route, persistence, and editor regression tests +proxy.ts Auth gate, CSP, rewrites, and public rate limits ``` ## Deployment -Deploys cleanly to Vercel. Set `DATABASE_URL` and `AUTH_SECRET` as project environment variables, plus `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` for Google sign-in. Set `OPENAI_API_KEY` to enable LLM-generated note titles; without it, Keep falls back to local zero-token title inference. Any Postgres provider works — the connection layer handles both managed (real CA) and self-hosted (self-signed) certs. +Production is self-hosted behind Caddy. The GitHub Actions workflow runs the +test, typecheck, and production-build gates before deploying the latest `main` +commit. `scripts/deploy.sh` provides the equivalent manual flow and refreshes +the project screenshot afterward. + +The app can also run on other Node.js hosts when the same environment variables +and a Postgres database are available. ## License -MIT +MIT — see [LICENSE](./LICENSE). diff --git a/__tests__/email.test.ts b/__tests__/email.test.ts new file mode 100644 index 0000000..11e8ce1 --- /dev/null +++ b/__tests__/email.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendVerificationEmail } from "@/lib/email"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("verification email delivery", () => { + it("fails registration visibly when production delivery is not configured", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("RESEND_API_KEY", ""); + + await expect( + sendVerificationEmail("person@example.com", "https://keeptxt.com/verify"), + ).rejects.toThrow("not configured"); + }); +}); diff --git a/__tests__/identifiers.test.ts b/__tests__/identifiers.test.ts new file mode 100644 index 0000000..1241cfb --- /dev/null +++ b/__tests__/identifiers.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { newShareToken } from "@/lib/shareToken"; +import { newId } from "@/lib/db"; + +describe("opaque identifiers", () => { + it("uses 128-bit internal ids", () => { + expect(newId()).toMatch(/^[0-9a-f]{32}$/); + }); + + it("uses 128-bit URL-safe share credentials", () => { + const tokens = Array.from({ length: 100 }, () => newShareToken()); + expect(new Set(tokens)).toHaveLength(tokens.length); + for (const token of tokens) expect(token).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); +}); diff --git a/__tests__/noteCreateIdempotency.test.ts b/__tests__/noteCreateIdempotency.test.ts new file mode 100644 index 0000000..c067919 --- /dev/null +++ b/__tests__/noteCreateIdempotency.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ query: vi.fn() })); + +vi.mock("@/auth", () => ({ + auth: vi.fn(async () => ({ user: { id: "owner" } })), +})); + +vi.mock("@/lib/db", () => ({ + newId: vi.fn(() => "f".repeat(32)), + ready: vi.fn(async () => {}), + pool: () => ({ query: mocks.query }), + rowToNote: (row: unknown) => row, +})); + +import { POST } from "@/app/api/notes/route"; + +const id = "0123456789abcdef0123456789abcdef"; +const row = { + id, + title: "Queued note", + summary: null, + color: null, + body: "body", + pinned: false, + archived: false, + trashed: false, + markdown: false, + highlight: false, + tags: [], + share_token: null, + created_at: "1", + updated_at: "1", +}; + +beforeEach(() => { + mocks.query.mockReset(); +}); + +describe("POST /api/notes idempotency", () => { + it("returns an existing owned note when an offline create is replayed", async () => { + mocks.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [row] }); + + const response = await POST( + new Request("https://keeptxt.com/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id, title: row.title, body: row.body }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ note: row }); + expect(mocks.query).toHaveBeenCalledTimes(2); + }); +}); diff --git a/__tests__/noteEditorAutosave.test.tsx b/__tests__/noteEditorAutosave.test.tsx index 82dd32b..b57cc8e 100644 --- a/__tests__/noteEditorAutosave.test.tsx +++ b/__tests__/noteEditorAutosave.test.tsx @@ -121,6 +121,38 @@ describe("NoteEditor autosave", () => { ); }); + it("flushes text typed while the initial create request is in flight", async () => { + let finishCreate!: (note: Note) => void; + const onCreate = vi.fn( + () => new Promise((resolve) => { finishCreate = resolve; }), + ); + const onUpdate = vi.fn(async () => {}); + render( + createElement(NoteEditor, { + target: { mode: "new" }, + onClose: vi.fn(), + onCreate, + onUpdate, + onTrash: vi.fn(), + onRestore: vi.fn(), + onRemove: vi.fn(), + presentation: "panel" as const, + }), + ); + + await typeBody("first snapshot"); + await advance(600); + expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ body: "first snapshot" })); + + await typeBody("newer text typed during save"); + await act(async () => finishCreate(makeNote({ id: "CREATED" }))); + + expect(onUpdate).toHaveBeenCalledWith( + "CREATED", + expect.objectContaining({ body: "newer text typed during save" }), + ); + }); + it("debounces updates to an existing note", async () => { const note = makeNote({ id: "N42", body: "old" }); const props = renderEditor({ mode: "edit", note }); @@ -150,4 +182,26 @@ describe("NoteEditor autosave", () => { ); expect(props.onClose).toHaveBeenCalled(); }); + + it("does not mark an unchanged note as edited when it closes", () => { + const props = renderEditor({ mode: "edit", note: makeNote({ id: "N42", body: "old" }) }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onUpdate).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalled(); + }); + + it("persists markdown preview mode per note", async () => { + const props = renderEditor({ mode: "edit", note: makeNote({ id: "N42", body: "# Heading" }) }); + + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + fireEvent.click(screen.getByRole("button", { name: "Preview markdown" })); + await advance(600); + + expect(props.onUpdate).toHaveBeenCalledWith( + "N42", + expect.objectContaining({ markdown: true, highlight: false }), + ); + }); }); diff --git a/__tests__/offlineDb.test.ts b/__tests__/offlineDb.test.ts new file mode 100644 index 0000000..aa68aa2 --- /dev/null +++ b/__tests__/offlineDb.test.ts @@ -0,0 +1,58 @@ +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { + addPendingOp, + cacheNotes, + getCachedNotes, + getPendingOps, + removePendingOp, +} from "@/lib/offlineDb"; +import type { Note } from "@/lib/types"; + +function note(id: string, body: string): Note { + return { + id, + title: body, + summary: null, + color: null, + body, + pinned: false, + archived: false, + trashed: false, + markdown: false, + highlight: false, + tags: [], + shareToken: null, + createdAt: 1, + updatedAt: 1, + }; +} + +describe("account-scoped offline storage", () => { + it("never returns one account's cached notes to another account", async () => { + const firstOwner = `first-${crypto.randomUUID()}`; + const secondOwner = `second-${crypto.randomUUID()}`; + await cacheNotes(firstOwner, [note("a", "first account")]); + await cacheNotes(secondOwner, [note("b", "second account")]); + + await expect(getCachedNotes(firstOwner)).resolves.toEqual([note("a", "first account")]); + await expect(getCachedNotes(secondOwner)).resolves.toEqual([note("b", "second account")]); + }); + + it("removes only operations that actually replayed", async () => { + const owner = `owner-${crypto.randomUUID()}`; + const first = await addPendingOp(owner, { + type: "update", + noteId: "a", + payload: { body: "first" }, + }); + const second = await addPendingOp(owner, { + type: "update", + noteId: "b", + payload: { body: "second" }, + }); + + await removePendingOp(owner, first.id); + await expect(getPendingOps(owner)).resolves.toEqual([second]); + }); +}); diff --git a/__tests__/resendRoute.test.ts b/__tests__/resendRoute.test.ts new file mode 100644 index 0000000..c71da1c --- /dev/null +++ b/__tests__/resendRoute.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { POST } from "@/app/api/auth/resend/route"; + +describe("/api/auth/resend", () => { + it("returns the same generic response for an invalid address", async () => { + const response = await POST( + new Request("https://keeptxt.com/api/auth/resend", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": "203.0.113.81", + }, + body: JSON.stringify({ email: "not-an-email" }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); +}); diff --git a/__tests__/safeRedirect.test.ts b/__tests__/safeRedirect.test.ts index 3c7010b..fdea3ba 100644 --- a/__tests__/safeRedirect.test.ts +++ b/__tests__/safeRedirect.test.ts @@ -11,6 +11,16 @@ describe("safeRedirect", () => { expect(safeRedirect("https://evil.com")).toBe("/"); expect(safeRedirect("//evil.com")).toBe("/"); expect(safeRedirect("http://evil.com/x")).toBe("/"); + expect(safeRedirect("/\\evil.com")).toBe("/"); + expect(safeRedirect("/\\/evil.com")).toBe("/"); + expect(safeRedirect("/%5cevil.com")).toBe("/"); + }); + + it("rejects dot-segment inputs that normalize to protocol-relative", () => { + expect(safeRedirect("/..//evil.com")).toBe("/"); + expect(safeRedirect("/%2e%2e//evil.com")).toBe("/"); + expect(safeRedirect("/x/..//..//evil.com")).toBe("/"); + expect(safeRedirect("/x/..//evil.com")).toBe("/"); }); it("falls back to / for empty/undefined", () => { @@ -18,5 +28,6 @@ describe("safeRedirect", () => { expect(safeRedirect(null)).toBe("/"); expect(safeRedirect("")).toBe("/"); expect(safeRedirect("note/abc")).toBe("/"); + expect(safeRedirect(["/note/abc"])).toBe("/"); }); }); diff --git a/__tests__/useNotesSync.test.tsx b/__tests__/useNotesSync.test.tsx new file mode 100644 index 0000000..631910a --- /dev/null +++ b/__tests__/useNotesSync.test.tsx @@ -0,0 +1,144 @@ +import "fake-indexeddb/auto"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { addPendingOp, getPendingOps } from "@/lib/offlineDb"; +import type { Note } from "@/lib/types"; +import { useNotes } from "@/lib/useNotes"; + +const NOTE_ID = "0123456789abcdef0123456789abcdef"; + +function note(overrides: Partial = {}): Note { + return { + id: NOTE_ID, + title: "Test", + summary: null, + color: null, + body: "body", + pinned: false, + archived: false, + trashed: false, + markdown: false, + highlight: false, + tags: [], + shareToken: null, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useNotes synchronization", () => { + it("serializes mutations for one note and ignores stale responses", async () => { + const owner = `owner-${crypto.randomUUID()}`; + let finishFirst!: (response: Response) => void; + const firstResponse = new Promise((resolve) => { finishFirst = resolve; }); + let patchRequests = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/notes") return Promise.resolve(json({ notes: [note()] })); + if (path === `/api/notes/${NOTE_ID}`) { + patchRequests += 1; + if (patchRequests === 1) return firstResponse; + return Promise.resolve(json({ note: note({ pinned: true, archived: true, updatedAt: 3 }) })); + } + throw new Error(`Unexpected request: ${path}`); + })); + + const { result } = renderHook(() => useNotes(owner)); + await waitFor(() => expect(result.current.hydrated).toBe(true)); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.update(NOTE_ID, { pinned: true }); + second = result.current.update(NOTE_ID, { archived: true }); + }); + + await waitFor(() => expect(patchRequests).toBe(1)); + expect(result.current.notes[0]).toMatchObject({ pinned: true, archived: true }); + + await act(async () => { + finishFirst(json({ note: note({ pinned: true, updatedAt: 2 }) })); + await first; + }); + await waitFor(() => expect(patchRequests).toBe(2)); + await act(async () => { await second; }); + + expect(result.current.notes[0]).toMatchObject({ pinned: true, archived: true }); + }); + + it("retains the failed operation and everything after it for a later replay", async () => { + const owner = `owner-${crypto.randomUUID()}`; + await addPendingOp(owner, { type: "update", noteId: NOTE_ID, payload: { pinned: true } }); + await addPendingOp(owner, { type: "update", noteId: NOTE_ID, payload: { archived: true } }); + vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true); + let replayAttempts = 0; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/notes") return Promise.resolve(json({ notes: [note()] })); + if (path === `/api/notes/${NOTE_ID}`) { + replayAttempts += 1; + return Promise.resolve(json({ error: "Unavailable" }, 503)); + } + throw new Error(`Unexpected request: ${path}`); + })); + + renderHook(() => useNotes(owner)); + await waitFor(() => expect(replayAttempts).toBe(1)); + + const remaining = await getPendingOps(owner); + expect(remaining).toHaveLength(2); + }); + + it("drops a permanently-rejected op instead of wedging the queue behind it", async () => { + const owner = `owner-${crypto.randomUUID()}`; + const GONE = "11111111111111111111111111111111"; + const OK = "22222222222222222222222222222222"; + // A 404 (note deleted elsewhere) is queued ahead of a good edit to another note. + await addPendingOp(owner, { type: "update", noteId: GONE, payload: { pinned: true } }); + await addPendingOp(owner, { type: "update", noteId: OK, payload: { archived: true } }); + vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true); + let okPatched = false; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/notes") return Promise.resolve(json({ notes: [] })); + if (path === `/api/notes/${GONE}`) return Promise.resolve(json({ error: "Not found" }, 404)); + if (path === `/api/notes/${OK}`) { + okPatched = true; + return Promise.resolve(json({ note: note({ id: OK, archived: true }) })); + } + throw new Error(`Unexpected request: ${path}`); + })); + + renderHook(() => useNotes(owner)); + + // The poison op is discarded and the following op still reaches the server; + // the queue drains to empty rather than sticking on the dead entry forever. + await waitFor(() => expect(okPatched).toBe(true)); + await waitFor(async () => expect(await getPendingOps(owner)).toHaveLength(0)); + }); + + it("stamps pending ops in strictly increasing order within a millisecond", async () => { + const owner = `owner-${crypto.randomUUID()}`; + vi.spyOn(Date, "now").mockReturnValue(1000); + const first = await addPendingOp(owner, { type: "create", noteId: "a", payload: {} }); + const second = await addPendingOp(owner, { type: "update", noteId: "a", payload: { body: "x" } }); + + expect(second.createdAt).toBeGreaterThan(first.createdAt); + const ops = await getPendingOps(owner); + expect(ops.map((op) => op.type)).toEqual(["create", "update"]); + }); +}); diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 7501f1b..999cfbc 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -41,19 +41,28 @@ export async function POST(req: Request) { if (issue) return NextResponse.json({ error: issue }, { status: 400 }); await ready(); - const existing = await pool().query("SELECT id FROM users WHERE lower(email) = $1", [email]); + const existing = await pool().query<{ id: string }>( + "SELECT id FROM users WHERE lower(email) = $1", + [email], + ); if (existing.rows[0]) { - // Generic message — don't reveal whether the email exists. + // Generic message — don't reveal whether the email exists. Crucially, never + // rewrite an existing (even unverified) account here: an UPDATE would let an + // attacker who knows a victim's email overwrite its password_hash before the + // victim verifies, then have the victim activate an attacker-set password. + // A legit unverified user re-requests their link via /api/auth/resend, which + // refreshes only the token and leaves the password untouched. return NextResponse.json({ error: "Could not create the account." }, { status: 409 }); } const id = newId(); const hash = await hashPassword(password); const token = randomBytes(32).toString("hex"); + const now = Date.now(); await pool().query( `INSERT INTO users (id, email, name, password_hash, verify_token, verify_token_expires, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [id, email, null, hash, token, Date.now() + VERIFY_TOKEN_TTL_MS, Date.now()], + [id, email, null, hash, token, now + VERIFY_TOKEN_TTL_MS, now], ); void recordSecurityEvent("register", { @@ -67,8 +76,11 @@ export async function POST(req: Request) { try { await sendVerificationEmail(email, verifyUrl); } catch (err) { - // Don't fail sign-up if the email send fails — the user can re-request. logger.error("verification email failed", { route: "auth:register", err, to: maskEmail(email) }); + return NextResponse.json( + { error: "Your account was saved, but the verification email could not be sent. Try again." }, + { status: 503 }, + ); } return NextResponse.json({ ok: true }); diff --git a/app/api/auth/resend/route.ts b/app/api/auth/resend/route.ts new file mode 100644 index 0000000..f142d15 --- /dev/null +++ b/app/api/auth/resend/route.ts @@ -0,0 +1,52 @@ +import { randomBytes } from "crypto"; +import { NextResponse } from "next/server"; +import { pool, ready } from "@/lib/db"; +import { sendVerificationEmail } from "@/lib/email"; +import { logger, maskEmail } from "@/lib/logger"; +import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; +import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; + +export const runtime = "nodejs"; + +const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; +const resendRateLimit = createTokenBucketRateLimiter({ limit: 6, windowMs: 60_000 }); +const response = () => NextResponse.json({ ok: true }); + +export async function POST(req: Request) { + const limited = enforceIpRateLimit( + resendRateLimit, + req.headers, + "auth-resend", + "Too many attempts. Try again shortly.", + ); + if (limited) return limited; + + const body = await req.json().catch(() => null); + const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : ""; + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return response(); + + await ready(); + const token = randomBytes(32).toString("hex"); + const now = Date.now(); + const { rows } = await pool().query<{ id: string }>( + `UPDATE users + SET verify_token = $1, verify_token_expires = $2, updated_at = $3 + WHERE lower(email) = $4 AND email_verified IS NULL + RETURNING id`, + [token, now + VERIFY_TOKEN_TTL_MS, now, email], + ); + if (!rows[0]) return response(); + + const origin = process.env.AUTH_URL ?? new URL(req.url).origin; + const verifyUrl = `${origin.replace(/\/$/, "")}/api/auth/verify?token=${token}`; + try { + await sendVerificationEmail(email, verifyUrl); + } catch (error) { + logger.error("verification resend failed", { + route: "auth:resend", + error, + to: maskEmail(email), + }); + } + return response(); +} diff --git a/app/api/enc/salt/route.ts b/app/api/enc/salt/route.ts deleted file mode 100644 index 022560f..0000000 --- a/app/api/enc/salt/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -// GET — returns the caller's enc_salt, or null if encryption is not yet set up. -// POST — sets the enc_salt for the first time; ignored if already set. -// -// The salt is not secret: it individualises the PBKDF2 key so two users with -// the same passphrase get different keys. The server never sees the passphrase -// or the derived key — only the salt and the resulting ciphertext. -import { NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { pool, ready } from "@/lib/db"; - -export async function GET() { - const session = await auth(); - if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - await ready(); - const { rows } = await pool().query<{ enc_salt: string | null }>( - "SELECT enc_salt FROM users WHERE id = $1", - [session.user.id], - ); - return NextResponse.json({ salt: rows[0]?.enc_salt ?? null }); -} - -export async function POST(req: Request) { - const session = await auth(); - if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { salt } = await req.json() as { salt?: string }; - if (!salt || !/^[0-9a-f]{64}$/.test(salt)) { - return NextResponse.json({ error: "salt must be 64 lowercase hex chars" }, { status: 400 }); - } - await ready(); - // Only set if not already present — changing the salt would make all existing - // ciphertext unrecoverable. Return whichever salt is now stored so the client - // derives its key from the real value rather than the one it proposed. - await pool().query( - "UPDATE users SET enc_salt = $1 WHERE id = $2 AND enc_salt IS NULL", - [salt, session.user.id], - ); - const { rows } = await pool().query<{ enc_salt: string | null }>( - "SELECT enc_salt FROM users WHERE id = $1", - [session.user.id], - ); - return NextResponse.json({ salt: rows[0]?.enc_salt ?? salt }); -} - -export async function DELETE() { - const session = await auth(); - if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - await ready(); - await pool().query("UPDATE users SET enc_salt = NULL WHERE id = $1", [session.user.id]); - return NextResponse.json({ ok: true }); -} diff --git a/app/api/native/exchange/route.ts b/app/api/native/exchange/route.ts index 98c3e52..594524b 100644 --- a/app/api/native/exchange/route.ts +++ b/app/api/native/exchange/route.ts @@ -12,7 +12,7 @@ const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // Trades the one-time code minted by /native/bridge for the session cookie, so // the native app's URLSession becomes authenticated. Public (it runs before a -// session exists — see the allowlist in middleware.ts); the high-entropy, + // session exists — see the allowlist in proxy.ts); the high-entropy, // single-use code is the credential, so there is nothing to leak without it. export async function POST(req: Request) { const body = await req.json().catch(() => null); diff --git a/app/api/notes/[id]/route.ts b/app/api/notes/[id]/route.ts index c3ad4c7..805d74f 100644 --- a/app/api/notes/[id]/route.ts +++ b/app/api/notes/[id]/route.ts @@ -3,7 +3,12 @@ import { auth } from "@/auth"; import { pool, ready, rowToNote, NoteRow } from "@/lib/db"; import { internalError } from "@/lib/apiError"; import { isNoteColor } from "@/lib/noteColors"; -import { MAX_NOTE_BODY, tagsInvalid } from "@/lib/noteLimits"; +import { + MAX_NOTE_BODY, + MAX_NOTE_SUMMARY, + MAX_NOTE_TITLE, + tagsInvalid, +} from "@/lib/noteLimits"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -23,8 +28,9 @@ const ALLOWED = new Set([ export async function PATCH( req: Request, - { params }: { params: { id: string } }, + { params }: { params: Promise<{ id: string }> }, ) { + const { id } = await params; const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -37,9 +43,27 @@ export async function PATCH( if ("color" in patch && patch.color !== null && !isNoteColor(patch.color)) { return NextResponse.json({ error: "Unknown color." }, { status: 400 }); } + if ("body" in patch && typeof patch.body !== "string") { + return NextResponse.json({ error: "Invalid note body." }, { status: 400 }); + } if (typeof patch.body === "string" && patch.body.length > MAX_NOTE_BODY) { return NextResponse.json({ error: "Note is too large." }, { status: 413 }); } + if ("title" in patch && (typeof patch.title !== "string" || patch.title.length > MAX_NOTE_TITLE)) { + return NextResponse.json({ error: "Invalid title." }, { status: 400 }); + } + if ( + "summary" in patch && + patch.summary !== null && + (typeof patch.summary !== "string" || patch.summary.length > MAX_NOTE_SUMMARY) + ) { + return NextResponse.json({ error: "Invalid summary." }, { status: 400 }); + } + for (const key of ["pinned", "archived", "trashed", "markdown", "highlight"]) { + if (key in patch && typeof patch[key] !== "boolean") { + return NextResponse.json({ error: `Invalid ${key} value.` }, { status: 400 }); + } + } if ("tags" in patch && tagsInvalid(patch.tags)) { return NextResponse.json({ error: "Invalid tags." }, { status: 400 }); } @@ -58,14 +82,14 @@ export async function PATCH( values.push(Date.now()); const idPlaceholder = `$${i++}`; const userPlaceholder = `$${i++}`; - values.push(params.id); + values.push(id); values.push(session.user.id); if (sets.length === 1) { // only updated_at — nothing meaningful changed const { rows } = await pool().query( `SELECT * FROM notes WHERE id = $1 AND user_id = $2`, - [params.id, session.user.id], + [id, session.user.id], ); if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); @@ -89,8 +113,9 @@ export async function PATCH( export async function DELETE( _req: Request, - { params }: { params: { id: string } }, + { params }: { params: Promise<{ id: string }> }, ) { + const { id } = await params; const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -99,7 +124,7 @@ export async function DELETE( await ready(); await pool().query( `DELETE FROM notes WHERE id = $1 AND user_id = $2`, - [params.id, session.user.id], + [id, session.user.id], ); return NextResponse.json({ ok: true }); } catch (err) { diff --git a/app/api/notes/[id]/share/route.ts b/app/api/notes/[id]/share/route.ts index 69133c3..7493ea5 100644 --- a/app/api/notes/[id]/share/route.ts +++ b/app/api/notes/[id]/share/route.ts @@ -1,23 +1,18 @@ -import crypto from "crypto"; import { NextResponse } from "next/server"; import { auth } from "@/auth"; import { pool, ready, rowToNote, NoteRow } from "@/lib/db"; import { internalError, isUniqueViolation } from "@/lib/apiError"; import { recordSecurityEvent } from "@/lib/audit"; +import { newShareToken } from "@/lib/shareToken"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -function newShareToken() { - // 4 random bytes → 8-char uppercase hex (~4.3B keyspace). Short and pretty - // in URLs; existing longer tokens still work because they're opaque strings. - return crypto.randomBytes(4).toString("hex").toUpperCase(); -} - export async function POST( req: Request, - { params }: { params: { id: string } }, + { params }: { params: Promise<{ id: string }> }, ) { + const { id } = await params; const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -30,7 +25,7 @@ export async function POST( SET share_token = COALESCE(share_token, $1), updated_at = $2 WHERE id = $3 AND user_id = $4 RETURNING *`, - [token, Date.now(), params.id, session.user.id], + [token, Date.now(), id, session.user.id], ); if (!rows[0]) { return NextResponse.json({ error: "Not found" }, { status: 404 }); @@ -38,7 +33,7 @@ export async function POST( void recordSecurityEvent("share.create", { userId: session.user.id, headers: req.headers, - meta: { noteId: params.id }, + meta: { noteId: id }, }); return NextResponse.json({ note: rowToNote(rows[0]) }); } catch (err) { @@ -48,8 +43,9 @@ export async function POST( export async function DELETE( req: Request, - { params }: { params: { id: string } }, + { params }: { params: Promise<{ id: string }> }, ) { + const { id } = await params; const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -61,7 +57,7 @@ export async function DELETE( SET share_token = NULL, updated_at = $1 WHERE id = $2 AND user_id = $3 RETURNING *`, - [Date.now(), params.id, session.user.id], + [Date.now(), id, session.user.id], ); if (!rows[0]) { return NextResponse.json({ error: "Not found" }, { status: 404 }); @@ -69,7 +65,7 @@ export async function DELETE( void recordSecurityEvent("share.revoke", { userId: session.user.id, headers: req.headers, - meta: { noteId: params.id }, + meta: { noteId: id }, }); return NextResponse.json({ note: rowToNote(rows[0]) }); } catch (err) { @@ -80,8 +76,9 @@ export async function DELETE( // Set a custom (vanity) share token on a note. export async function PUT( req: Request, - { params }: { params: { id: string } }, + { params }: { params: Promise<{ id: string }> }, ) { + const { id } = await params; const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -98,7 +95,7 @@ export async function PUT( await ready(); const taken = await pool().query( `SELECT id FROM notes WHERE share_token = $1 AND id <> $2`, - [token, params.id], + [token, id], ); if (taken.rows[0]) { return NextResponse.json({ error: "That link is already taken." }, { status: 409 }); @@ -106,7 +103,7 @@ export async function PUT( const { rows } = await pool().query( `UPDATE notes SET share_token = $1, updated_at = $2 WHERE id = $3 AND user_id = $4 RETURNING *`, - [token, Date.now(), params.id, session.user.id], + [token, Date.now(), id, session.user.id], ); if (!rows[0]) { return NextResponse.json({ error: "Not found" }, { status: 404 }); @@ -114,7 +111,7 @@ export async function PUT( void recordSecurityEvent("share.create", { userId: session.user.id, headers: req.headers, - meta: { noteId: params.id, custom: true }, + meta: { noteId: id, custom: true }, }); return NextResponse.json({ note: rowToNote(rows[0]) }); } catch (err) { diff --git a/app/api/notes/import/route.ts b/app/api/notes/import/route.ts index 6d5ed6d..08346b1 100644 --- a/app/api/notes/import/route.ts +++ b/app/api/notes/import/route.ts @@ -13,8 +13,7 @@ export const dynamic = "force-dynamic"; // note count and decompressed size to defend against zip bombs. const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; -// Deterministic 8-hex id so re-importing the same note dedupes via ON CONFLICT, -// while still matching the app-wide 8-char id convention. +// Deterministic 128-bit id so re-importing the same note dedupes via ON CONFLICT. function googleKeepImportId(userId: string, note: KeepImportNote) { return crypto .createHash("sha1") @@ -24,7 +23,7 @@ function googleKeepImportId(userId: string, note: KeepImportNote) { .update("\0") .update(String(note.createdAt)) .digest("hex") - .slice(0, 8); + .slice(0, 32); } export async function POST(req: Request) { diff --git a/app/api/notes/route.ts b/app/api/notes/route.ts index f09b589..26bd053 100644 --- a/app/api/notes/route.ts +++ b/app/api/notes/route.ts @@ -1,13 +1,35 @@ import { NextResponse } from "next/server"; +import { createHash } from "crypto"; import { auth } from "@/auth"; import { newId, pool, ready, rowToNote, NoteRow } from "@/lib/db"; import { generateNoteMeta } from "@/lib/titleModel"; import { internalError } from "@/lib/apiError"; -import { MAX_NOTE_BODY, tagsInvalid } from "@/lib/noteLimits"; +import { + MAX_NOTE_BODY, + MAX_NOTE_SUMMARY, + MAX_NOTE_TITLE, + tagsInvalid, +} from "@/lib/noteLimits"; +import { isNoteColor } from "@/lib/noteColors"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const CLIENT_NOTE_KEY = /^[A-Za-z0-9_-]{1,64}$/; + +function noteIdForCreate(userId: string, requested: unknown) { + if (typeof requested !== "string") return newId(); + if (/^[0-9a-f]{32}$/.test(requested)) return requested; + // Older guest notes used short/local ids. Derive a stable, account-specific + // 128-bit id so retries stay idempotent without reintroducing global 32-bit ids. + return createHash("sha256") + .update(userId) + .update("\0") + .update(requested) + .digest("hex") + .slice(0, 32); +} + export async function GET() { const session = await auth(); if (!session?.user?.id) { @@ -40,8 +62,17 @@ export async function POST(req: Request) { if (body.tags !== undefined && tagsInvalid(body.tags)) { return NextResponse.json({ error: "Invalid tags." }, { status: 400 }); } + if (body.id !== undefined && (typeof body.id !== "string" || !CLIENT_NOTE_KEY.test(body.id))) { + return NextResponse.json({ error: "Invalid note id." }, { status: 400 }); + } + if (body.color !== undefined && body.color !== null && !isNoteColor(body.color)) { + return NextResponse.json({ error: "Unknown color." }, { status: 400 }); + } let title = String(body.title ?? ""); let summary = typeof body.summary === "string" ? body.summary : null; + if (title.length > MAX_NOTE_TITLE || (summary?.length ?? 0) > MAX_NOTE_SUMMARY) { + return NextResponse.json({ error: "Note metadata is too large." }, { status: 400 }); + } // The client normally supplies both (one Haiku call); only fall back to // generating here when it didn't. if (!title) { @@ -49,28 +80,41 @@ export async function POST(req: Request) { title = meta.title; summary = summary ?? meta.summary; } - const id = newId(); + const id = noteIdForCreate(session.user.id, body.id); const now = Date.now(); const tags = Array.isArray(body.tags) ? body.tags.map(String) : []; const { rows } = await pool().query( - `INSERT INTO notes (id, user_id, title, summary, body, pinned, archived, trashed, markdown, highlight, tags, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, false, $8, $9, $10, $11, $11) + `INSERT INTO notes (id, user_id, title, summary, color, body, pinned, archived, trashed, markdown, highlight, tags, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9, $10, $11, $12, $12) + ON CONFLICT (id) DO NOTHING RETURNING *`, [ id, session.user.id, title, summary, + body.color ?? null, noteBody, - Boolean(body.pinned ?? false), - Boolean(body.archived ?? false), - Boolean(body.markdown ?? false), - Boolean(body.highlight ?? false), + body.pinned === true, + body.archived === true, + body.markdown === true, + body.highlight === true, tags, now, ], ); - return NextResponse.json({ note: rowToNote(rows[0]) }, { status: 201 }); + if (rows[0]) { + return NextResponse.json({ note: rowToNote(rows[0]) }, { status: 201 }); + } + + const existing = await pool().query( + `SELECT * FROM notes WHERE id = $1 AND user_id = $2`, + [id, session.user.id], + ); + if (!existing.rows[0]) { + return NextResponse.json({ error: "Note id is already in use." }, { status: 409 }); + } + return NextResponse.json({ note: rowToNote(existing.rows[0]) }); } catch (err) { return internalError("notes:list-create", err); } diff --git a/app/layout.tsx b/app/layout.tsx index e295acd..d7b3a18 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,14 +19,14 @@ export const viewport: Viewport = { maximumScale: 1, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { - // CSP nonce minted per-request in middleware; both inline bootstrap scripts - // carry it so they survive the script-src nonce policy (see middleware.ts). - const nonce = headers().get("x-nonce") ?? undefined; + // CSP nonce minted per-request in proxy.ts; both inline bootstrap scripts + // carry it so they survive the script-src nonce policy. + const nonce = (await headers()).get("x-nonce") ?? undefined; return ( as notes open; this route makes // those URLs real so refresh and bookmarks land back on the same note. -export default function Page({ params }: { params: { noteId: string } }) { +export default async function Page({ params }: { params: Promise<{ noteId: string }> }) { + const session = await auth(); + const { noteId } = await params; return ( <>
- + ); } diff --git a/app/offline/page.tsx b/app/offline/page.tsx new file mode 100644 index 0000000..5fc0ae4 --- /dev/null +++ b/app/offline/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Logo } from "@/components/Logo"; + +export const metadata: Metadata = { + title: "Offline · Keep", +}; + +// Neutral, personalization-free fallback the service worker serves when a +// navigation happens with no network. It renders no account data, so it's safe +// to precache and show to anyone on the device — the signed-in app shell +// deliberately isn't cached, which is why offline navigations land here. +export default function OfflinePage() { + return ( +
+
+
+ +
+

+ You're offline +

+

+ No connection right now. Your notes are safe and load again the moment + you're back online. +

+ + Try again + +
+
+ ); +} diff --git a/app/p/[token]/page.tsx b/app/p/[token]/page.tsx index c8fa641..b34230e 100644 --- a/app/p/[token]/page.tsx +++ b/app/p/[token]/page.tsx @@ -77,9 +77,10 @@ function isSameDay(a: number, b: number) { export default async function SharedNotePage({ params, }: { - params: { token: string }; + params: Promise<{ token: string }>; }) { - const note = await loadShared(params.token); + const { token } = await params; + const note = await loadShared(token); if (!note) notFound(); const title = displayTitle(note.title, note.body); @@ -115,7 +116,7 @@ export default async function SharedNotePage({
.txt → /p//raw.txt rewrite in middleware. +// Reached via the /p/.txt → /p//raw.txt rewrite in proxy.ts. // Returns the note body as text/plain, with a Content-Disposition that hints // a sensible filename to browsers prompting a download. export async function GET( _req: Request, - { params }: { params: { token: string } }, + { params }: { params: Promise<{ token: string }> }, ) { + const { token } = await params; await ready(); const { rows } = await pool().query( `SELECT * FROM notes WHERE share_token = $1 AND trashed = false LIMIT 1`, - [params.token], + [token], ); if (!rows[0]) { return new Response("Not found", { status: 404 }); diff --git a/app/page.tsx b/app/page.tsx index 08302dd..edf02c4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,13 +1,15 @@ import { Header } from "@/components/Header"; import { NotesView } from "@/components/NotesView"; +import { auth } from "@/auth"; // The page is usable for guests. Header shows auth state server-side; NotesView // stores guest notes locally and syncs them after sign-in. -export default function Page() { +export default async function Page() { + const session = await auth(); return ( <>
- + ); } diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 08c4753..4ee0631 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -3,12 +3,14 @@ import { redirect } from "next/navigation"; import { safeRedirect } from "@/lib/safeRedirect"; import { Logo } from "@/components/Logo"; import { EmailPasswordSignIn } from "@/components/EmailPasswordSignIn"; +import { ResendVerificationForm } from "@/components/ResendVerificationForm"; export default async function SignInPage({ - searchParams, + searchParams: searchParamsPromise, }: { - searchParams: { from?: string; verified?: string; error?: string }; + searchParams: Promise<{ from?: string; verified?: string; error?: string }>; }) { + const searchParams = await searchParamsPromise; // Only honor same-origin relative paths in ?from= so the post-login redirect // can't be hijacked to an external site. const safeFrom = safeRedirect(searchParams.from); @@ -32,9 +34,12 @@ export default async function SignInPage({

)} {searchParams.error === "verify" && ( -

- That verification link is invalid or expired. -

+ <> +

+ That verification link is invalid or expired. +

+ + )} diff --git a/auth.config.ts b/auth.config.ts index fa4ca66..db3f299 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,6 +1,6 @@ import type { NextAuthConfig } from "next-auth"; -// Edge-safe slice of the auth config used by middleware.ts. Keep this file +// DB-free slice of the auth config used by proxy.ts. Keep this file // free of Node-only imports (pg, etc.) — the full config in auth.ts // re-exports providers and callbacks that need Node. export const authConfig: NextAuthConfig = { diff --git a/auth.ts b/auth.ts index 5cab036..df5ebbd 100644 --- a/auth.ts +++ b/auth.ts @@ -92,8 +92,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (session.user && token.sub) session.user.id = token.sub; return session; }, - // Mirror Google users into our `users` table so email/password sign-in and - // per-account settings (enc_salt) resolve back to the same account. + // Mirror Google users into our `users` table so email/password sign-in + // resolves back to the same account. async signIn({ user, account }) { if (account?.provider !== "google") return true; const id = account.providerAccountId ?? user.id; diff --git a/components/EncryptionSetup.tsx b/components/EncryptionSetup.tsx deleted file mode 100644 index 343f52b..0000000 --- a/components/EncryptionSetup.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { XIcon } from "@/components/Icons"; - -export function EncryptionSetup({ - onSetup, - onClose, -}: { - onSetup: (passphrase: string) => Promise; - onClose: () => void; -}) { - const [passphrase, setPassphrase] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(""); - const [busy, setBusy] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - if (passphrase.length < 8) { - setError("Passphrase must be at least 8 characters."); - return; - } - if (passphrase !== confirm) { - setError("Passphrases don't match."); - return; - } - setBusy(true); - try { - await onSetup(passphrase); - } catch { - setError("Failed to enable encryption. Try again."); - } finally { - setBusy(false); - } - } - - return ( -
-
-
-

- Enable end-to-end encryption -

- -
- -

- Choose a passphrase. It never leaves this device — the server only - stores your encrypted notes and cannot read them without it. - If you forget your passphrase your text cannot be recovered. -

- -
- setPassphrase(e.target.value)} - autoComplete="new-password" - className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-muted)] focus:border-[var(--color-accent)] focus:outline-none" - /> - setConfirm(e.target.value)} - autoComplete="new-password" - className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-muted)] focus:border-[var(--color-accent)] focus:outline-none" - /> - {error && ( -

{error}

- )} - -
-
-
- ); -} diff --git a/components/EncryptionUnlock.tsx b/components/EncryptionUnlock.tsx deleted file mode 100644 index e3adb7e..0000000 --- a/components/EncryptionUnlock.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { useState } from "react"; - -export function EncryptionUnlock({ - onUnlock, - onReset, -}: { - onUnlock: (passphrase: string) => Promise; - onReset: () => void; -}) { - const [passphrase, setPassphrase] = useState(""); - const [error, setError] = useState(""); - const [busy, setBusy] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - setBusy(true); - try { - const ok = await onUnlock(passphrase); - if (!ok) setError("Wrong passphrase — try again."); - } finally { - setBusy(false); - } - } - - return ( -
-
-

- Unlock your notes -

-

- Your notes are end-to-end encrypted. Enter your passphrase to decrypt - them — it never leaves this device. -

- -
- setPassphrase(e.target.value)} - autoFocus - autoComplete="current-password" - className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-muted)] focus:border-[var(--color-accent)] focus:outline-none" - /> - {error && ( -

{error}

- )} - -
- - -
-
- ); -} diff --git a/components/NoteEditor.tsx b/components/NoteEditor.tsx index 3b9ed48..052365b 100644 --- a/components/NoteEditor.tsx +++ b/components/NoteEditor.tsx @@ -74,7 +74,7 @@ export function NoteEditor({ onClose: () => void; onBack?: () => void; onCreate: (n: Partial) => Promise; - onUpdate: (id: string, patch: Partial) => void; + onUpdate: (id: string, patch: Partial) => Promise; onTrash: (id: string) => void; onRestore: (id: string) => void; onRemove: (id: string) => void; @@ -94,6 +94,7 @@ export function NoteEditor({ const [actionsOpen, setActionsOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [titleEditing, setTitleEditing] = useState(false); + const cancelTitleEditRef = useRef(false); const bodyRef = useRef(null); const [highlight, setHighlight] = useState(false); const plainRef = useRef(null); @@ -103,6 +104,49 @@ export function NoteEditor({ useAutohideScrollbar(scrollRef); const createdIdRef = useRef(null); const creatingRef = useRef(false); + const revisionRef = useRef(0); + const mountedRef = useRef(true); + const draftRef = useRef({ body, pinned, archived, markdown: previewOpen, highlight }); + draftRef.current = { body, pinned, archived, markdown: previewOpen, highlight }; + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + const startCreate = useCallback( + async ( + draft: { + body: string; + pinned: boolean; + archived: boolean; + markdown: boolean; + highlight: boolean; + }, + submittedRevision: number, + ) => { + if (creatingRef.current) return; + creatingRef.current = true; + try { + const note = await onCreate(draft); + if (!note) return; + createdIdRef.current = note.id; + + const latestRevision = revisionRef.current; + if (latestRevision !== submittedRevision) { + await onUpdate(note.id, draftRef.current); + if (mountedRef.current && revisionRef.current === latestRevision) { + setDirty(false); + } + } else if (mountedRef.current) { + setDirty(false); + } + } finally { + creatingRef.current = false; + } + }, + [onCreate, onUpdate], + ); // Search highlighting: when a note is opened from search, paint a yellow box // on every match and let Tab cycle through them (see the plain-editor overlay). const [findQuery, setFindQuery] = useState(null); @@ -136,11 +180,13 @@ export function NoteEditor({ setBody(target.note.body); setPinned(target.note.pinned); setArchived(target.note.archived); + setPreviewOpen(Boolean(target.note.markdown)); setHighlight(Boolean(target.note.highlight)); } else { setBody(""); setPinned(false); setArchived(false); + setPreviewOpen(false); setHighlight(false); } setDirty(false); @@ -150,6 +196,7 @@ export function NoteEditor({ setTitleEditing(false); createdIdRef.current = null; creatingRef.current = false; + revisionRef.current = 0; const query = target.mode === "edit" ? target.highlightQuery?.trim() : undefined; const matchAt = @@ -225,11 +272,11 @@ export function NoteEditor({ switchFlipRef.current = false; const el = panelRef.current; if (!el) return; - el.getAnimations().forEach((a) => { + el.getAnimations?.().forEach((a) => { if (a.id === "panel-flip") a.cancel(); }); // The incoming text fades up from the background while the card resizes. - scrollRef.current?.animate( + scrollRef.current?.animate?.( [{ opacity: 0 }, { opacity: 0, offset: 0.3 }, { opacity: 1 }], { duration: 300, easing: "ease-out" }, ); @@ -238,6 +285,10 @@ export function NoteEditor({ const prev = prevPanelHeightRef.current; const next = el.offsetHeight; if (!firstOpen && prev > 0 && Math.abs(prev - next) > 2) { + if (!el.animate) { + prevPanelHeightRef.current = next; + return; + } const anim = el.animate( [{ height: `${prev}px` }, { height: `${next}px` }], { duration: 260, easing: "cubic-bezier(0.33, 1, 0.68, 1)" }, @@ -254,7 +305,7 @@ export function NoteEditor({ useLayoutEffect(() => { const el = panelRef.current; if (!el) return; - if (el.getAnimations().some((a) => a.id === "panel-flip")) return; + if (el.getAnimations?.().some((a) => a.id === "panel-flip")) return; prevPanelHeightRef.current = el.offsetHeight; }); @@ -308,37 +359,41 @@ export function NoteEditor({ useEffect(() => { if (!target || !dirty) return; if (target.mode === "edit") { + const submittedRevision = revisionRef.current; const timer = window.setTimeout(() => { - onUpdate(target.note.id, { body, pinned, archived, highlight }); - setDirty(false); + void Promise.resolve(onUpdate(target.note.id, draftRef.current)).then(() => { + if (mountedRef.current && revisionRef.current === submittedRevision) { + setDirty(false); + } + }); }, 550); return () => window.clearTimeout(timer); } if (target.mode === "new") { if (createdIdRef.current) { const id = createdIdRef.current; + const submittedRevision = revisionRef.current; const timer = window.setTimeout(() => { - onUpdate(id, { body, pinned, archived, highlight }); - setDirty(false); + void Promise.resolve(onUpdate(id, draftRef.current)).then(() => { + if (mountedRef.current && revisionRef.current === submittedRevision) { + setDirty(false); + } + }); }, 550); return () => window.clearTimeout(timer); } if (!body.trim() || creatingRef.current) return; - const timer = window.setTimeout(async () => { + const submittedRevision = revisionRef.current; + const timer = window.setTimeout(() => { if (createdIdRef.current || creatingRef.current) return; - creatingRef.current = true; - const note = await onCreate({ body, pinned, archived, highlight }); - creatingRef.current = false; - if (note) { - createdIdRef.current = note.id; - setDirty(false); - } + void startCreate(draftRef.current, submittedRevision); }, 550); return () => window.clearTimeout(timer); } - }, [archived, body, dirty, highlight, onCreate, onUpdate, pinned, target]); + }, [archived, body, dirty, highlight, onUpdate, pinned, previewOpen, startCreate, target]); function markBody(value: string) { + revisionRef.current += 1; setBody(value); setDirty(true); // Editing ends the search session (matches would shift out from under it). @@ -346,15 +401,31 @@ export function NoteEditor({ } function togglePinned() { + revisionRef.current += 1; setPinned((value) => !value); setDirty(true); } function toggleArchived() { + revisionRef.current += 1; setArchived((value) => !value); setDirty(true); } + function toggleHighlightMode() { + revisionRef.current += 1; + setHighlight((value) => !value); + setPreviewOpen(false); + setDirty(true); + } + + function toggleMarkdownPreview() { + revisionRef.current += 1; + setPreviewOpen((value) => !value); + setHighlight(false); + setDirty(true); + } + async function uploadImage(file: File) { setUploading(true); try { @@ -451,19 +522,17 @@ export function NoteEditor({ } function flushEdit() { - if (!target || target.mode !== "edit") return; - onUpdate(target.note.id, { body, pinned, archived, highlight }); - setDirty(false); + if (!target || target.mode !== "edit" || !dirty) return; + void onUpdate(target.note.id, draftRef.current); } function close() { if (!target) return; if (target.mode === "new") { - if (createdIdRef.current) { - onUpdate(createdIdRef.current, { body, pinned, archived, highlight }); + if (createdIdRef.current && dirty) { + void onUpdate(createdIdRef.current, draftRef.current); } else if (body.trim() && !creatingRef.current) { - creatingRef.current = true; - onCreate({ body, pinned, archived, highlight }); + void startCreate(draftRef.current, revisionRef.current); } } else { flushEdit(); @@ -519,18 +588,21 @@ export function NoteEditor({ { + cancelTitleEditRef.current = false; setTitleEditing(true); setTitleDraft(shownTitle); }} onChange={(e) => setTitleDraft(e.target.value)} onBlur={() => { setTitleEditing(false); - commitTitle(); + if (!cancelTitleEditRef.current) commitTitle(); + cancelTitleEditRef.current = false; }} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") e.currentTarget.blur(); if (e.key === "Escape") { + cancelTitleEditRef.current = true; setTitleDraft(shownTitle); e.currentTarget.blur(); } @@ -557,7 +629,7 @@ export function NoteEditor({ <> + {error && Could not send a new link.} + + ); +} diff --git a/components/SearchOverlay.tsx b/components/SearchOverlay.tsx index 960dd79..fa653fb 100644 --- a/components/SearchOverlay.tsx +++ b/components/SearchOverlay.tsx @@ -16,7 +16,7 @@ export function SearchOverlay({ }: { query: string; setQuery: (value: string) => void; - searchRef: React.RefObject; + searchRef: React.RefObject; results: Note[]; activeId: string | null; setActiveId: (id: string) => void; diff --git a/components/SettingsPane.tsx b/components/SettingsPane.tsx index d0feff0..9ab61e2 100644 --- a/components/SettingsPane.tsx +++ b/components/SettingsPane.tsx @@ -28,7 +28,7 @@ export function SettingsPane({ onClose, }: { importing: boolean; - importRef: React.RefObject; + importRef: React.RefObject; notes: Note[]; isGuest: boolean; counts: { archive: number; trash: number }; @@ -36,7 +36,7 @@ export function SettingsPane({ onOpenTrash: () => void; onImportClick: () => void; onImport: (event: React.ChangeEvent) => void; - importTextsRef: React.RefObject; + importTextsRef: React.RefObject; onImportTextsClick: () => void; onImportTexts: (event: React.ChangeEvent) => void; onGuestExport: () => void; diff --git a/ios/Keep/Models/Note.swift b/ios/Keep/Models/Note.swift index 191fa9d..0e4c090 100644 --- a/ios/Keep/Models/Note.swift +++ b/ios/Keep/Models/Note.swift @@ -21,17 +21,14 @@ struct Note: Identifiable, Codable, Equatable { var updatedDate: Date { Date(timeIntervalSince1970: updatedAt / 1000) } /// Display title, matching the web: infers from the body when the stored - /// title is missing, too long/wordy, or just equals the body (e.g. legacy - /// notes whose title is `enc:` ciphertext over a plaintext body). + /// title is missing, too long/wordy, or just equals the body. var displayTitle: String { NoteTitle.preview(title: title, body: body) } - /// Card caption, or nil when there's nothing readable to show — including a - /// legacy `enc:` summary we can't decrypt natively (showing the ciphertext - /// would read as garbage, so hide it like the web does when locked). + /// Card caption, or nil when there's nothing readable to show. var displaySummary: String? { - guard let summary, !summary.isEmpty, !summary.hasPrefix("enc:") else { return nil } + guard let summary, !summary.isEmpty else { return nil } return summary } } diff --git a/ios/Keep/Services/NotesStore.swift b/ios/Keep/Services/NotesStore.swift index 5c86e0a..5f8c1f5 100644 --- a/ios/Keep/Services/NotesStore.swift +++ b/ios/Keep/Services/NotesStore.swift @@ -38,11 +38,16 @@ final class NotesStore { catch { handle(error) } } - func create(body: String) async { + @discardableResult + func create(body: String) async -> Note? { do { let note = try await api.create(body: body) notes.insert(note, at: 0) - } catch { handle(error) } + return note + } catch { + handle(error) + return nil + } } func update(_ id: String, patch: [String: Any]) async { diff --git a/ios/Keep/Support/NoteTitle.swift b/ios/Keep/Support/NoteTitle.swift index 51e09be..0fd7a48 100644 --- a/ios/Keep/Support/NoteTitle.swift +++ b/ios/Keep/Support/NoteTitle.swift @@ -3,10 +3,7 @@ import Foundation /// Swift port of `lib/inferTitle.ts`, so the iOS app derives a note's display /// title exactly like the web. When the stored title is missing, too long, too /// wordy, or simply equals the body, the web infers a title from the body's -/// first meaningful line instead of showing the raw stored value. Legacy notes -/// from the shelved encryption feature land here: their `title` is `enc:` -/// ciphertext (well over the length limit) while the body stayed plaintext, so -/// matching the web means those notes get a real, body-derived title too. +/// first meaningful line instead of showing the raw stored value. /// /// Kept in lockstep with the web logic and its `__tests__/inferTitle.test.ts`. enum NoteTitle { diff --git a/ios/Keep/Views/NoteEditorView.swift b/ios/Keep/Views/NoteEditorView.swift index cd06822..dedf6ad 100644 --- a/ios/Keep/Views/NoteEditorView.swift +++ b/ios/Keep/Views/NoteEditorView.swift @@ -42,8 +42,9 @@ struct NoteEditorView: View { if let id = note?.id ?? createdId { await store.update(id, patch: ["body": value]) } else if !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - await store.create(body: value) - createdId = store.notes.first?.id + if let created = await store.create(body: value) { + createdId = created.id + } } } } diff --git a/ios/KeepMac/MacNoteDetail.swift b/ios/KeepMac/MacNoteDetail.swift index 3307a0b..e601a91 100644 --- a/ios/KeepMac/MacNoteDetail.swift +++ b/ios/KeepMac/MacNoteDetail.swift @@ -74,9 +74,7 @@ struct MacNoteDetail: View { if let id = note?.id ?? createdId { await store.update(id, patch: ["body": value]) } else if !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let before = Set(store.notes.map(\.id)) - await store.create(body: value) - if let created = store.notes.first(where: { !before.contains($0.id) }) { + if let created = await store.create(body: value) { createdId = created.id onCreated(created.id) } diff --git a/ios/README.md b/ios/README.md index e40c59f..9b36550 100644 --- a/ios/README.md +++ b/ios/README.md @@ -90,8 +90,8 @@ gh variable set IOS_SCREENSHOT --body true ## Not done yet (intentionally) -- Offline cache / sync, search, markdown rendering, color picker, version - history, E2E encryption — all present on web, not yet ported. +- Offline cache / sync, search, markdown rendering, color picker, and version + history. - App icon / launch assets. ## Roadmap (suggested) diff --git a/lib/crypto.ts b/lib/crypto.ts deleted file mode 100644 index 765134a..0000000 --- a/lib/crypto.ts +++ /dev/null @@ -1,134 +0,0 @@ -// lib/crypto.ts — Client-side end-to-end encryption for note bodies -// -// Security model -// ────────────── -// 1. The user sets a passphrase that never leaves the browser. -// 2. PBKDF2 (600 000 iterations, SHA-256) derives a 256-bit AES-GCM key -// from that passphrase + a random per-user salt stored on the server. -// The salt isn't secret — it just ensures two users with the same -// passphrase get different keys. -// 3. Each note body is encrypted with a fresh 12-byte IV per save. -// The IV is prepended to the ciphertext so no side-channel is needed. -// 4. The server stores only ciphertext prefixed with "enc:". -// It never sees plaintext and cannot decrypt without the passphrase. -// 5. Legacy (pre-encryption) notes remain readable — decryptBody returns -// them unchanged if they don't start with "enc:". - -/** - * Derives a 256-bit AES-GCM key from a user passphrase and a server-stored salt. - * - * 600 000 PBKDF2 iterations matches the OWASP 2023 recommendation for SHA-256. - * The derived CryptoKey is marked non-extractable — the raw key bytes never - * leave the WebCrypto sandbox. - */ -export async function deriveKey( - passphrase: string, - saltHex: string, -): Promise { - const keyMaterial = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(passphrase), - "PBKDF2", - false, - ["deriveKey"], - ); - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: hexToBytes(saltHex), - iterations: 600_000, - hash: "SHA-256", - }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, // non-extractable: the key bytes stay inside WebCrypto - ["encrypt", "decrypt"], - ); -} - -/** - * Encrypts a note body with AES-GCM. - * - * A random 12-byte IV is generated per call and prepended to the ciphertext - * so the same plaintext never produces the same output. Returns a string of - * the form "enc:" — safe to store as TEXT in Postgres. - */ -export async function encryptBody( - key: CryptoKey, - plaintext: string, -): Promise { - const iv = crypto.getRandomValues(new Uint8Array(12)) as Uint8Array; - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - new TextEncoder().encode(plaintext), - ); - // Pack iv + ciphertext so the decrypt side needs one base64 decode. - const packed = new Uint8Array(12 + ciphertext.byteLength) as Uint8Array; - packed.set(iv); - packed.set(new Uint8Array(ciphertext) as Uint8Array, 12); - return "enc:" + bytesToBase64(packed); -} - -/** - * Decrypts a body produced by encryptBody. - * - * Returns the original plaintext, or the input unchanged if it is not - * an encrypted body (no "enc:" prefix). Throws on authentication failure, - * which means either the key is wrong or the ciphertext was tampered with. - */ -export async function decryptBody( - key: CryptoKey, - body: string, -): Promise { - if (!body.startsWith("enc:")) return body; // plaintext passthrough - const packed = base64ToBytes(body.slice(4)); - const plaintext = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: packed.slice(0, 12) }, - key, - packed.slice(12), - ); - return new TextDecoder().decode(plaintext); -} - -/** Generates a random 32-byte hex salt for a new user. Call once; persist server-side. */ -export function generateSalt(): string { - return bytesToHex(crypto.getRandomValues(new Uint8Array(32)) as Uint8Array); -} - -/** Returns true when a body was encrypted by encryptBody. */ -export function isEncrypted(body: string): boolean { - return body.startsWith("enc:"); -} - -// ── Encoding helpers ────────────────────────────────────────────────────────── - -function bytesToBase64(bytes: Uint8Array): string { - // Chunk the spread — String.fromCharCode(...bytes) overflows the argument - // limit (RangeError) for large notes. - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function base64ToBytes(b64: string): Uint8Array { - const raw = atob(b64); - const arr = new Uint8Array(raw.length) as Uint8Array; - for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); - return arr; -} - -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -function hexToBytes(hex: string): Uint8Array { - const arr = new Uint8Array(hex.length / 2) as Uint8Array; - for (let i = 0; i < arr.length; i++) { - arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return arr; -} diff --git a/lib/db.ts b/lib/db.ts index 840ae07..e80bdc7 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -5,7 +5,7 @@ import { logger } from "@/lib/logger"; // CA certificate for verifying the Postgres server's TLS cert. DATABASE_CA_CERT // may be the PEM contents inline or a path to a .pem/.crt file. When set, the // connection verifies the server identity (rejectUnauthorized: true); when -// unset we fall back to encrypt-but-don't-verify for self-signed localhost. +// unset we fall back to TLS without certificate verification for self-signed localhost. export function caCert(): string | undefined { const raw = process.env.DATABASE_CA_CERT; if (!raw || !raw.trim()) return undefined; @@ -43,8 +43,8 @@ function makePool(): Pool { const connectionString = url.toString(); // With a CA cert configured, verify the server's identity (defends against a - // MITM on the DB connection). Without one, fall back to encrypt-but-don't- - // verify — the norm for a self-signed Postgres on the same host. + // MITM on the DB connection). Without one, use TLS without certificate + // verification — the norm for a self-signed Postgres on the same host. const ca = caCert(); const ssl = ca ? { ca, rejectUnauthorized: true } @@ -119,10 +119,6 @@ async function bootstrap(): Promise { name TEXT, updated_at BIGINT NOT NULL ); - -- enc_salt stores a random 32-byte hex salt used by the client to derive - -- the AES-GCM encryption key via PBKDF2. NULL means encryption is disabled. - -- The salt is public; only the passphrase (never sent to the server) is secret. - ALTER TABLE users ADD COLUMN IF NOT EXISTS enc_salt TEXT; -- Email + password auth (additive; Google users have a null password_hash). ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BIGINT; @@ -171,13 +167,12 @@ async function bootstrap(): Promise { `); } -// Re-IDs any note that predates the 8-hex-char convention (old 12-char ids, -// GK-import ids, …) to a fresh 8-char id. -// Idempotent: once every id matches ^[0-9a-f]{8}$ this is a no-op. Each note is -// migrated in its own transaction so a failure mid-run is safe and resumable. +// Preserve existing 8-hex IDs, but migrate any other legacy shape to the current +// 128-bit form. Each note is migrated in its own transaction so a failure +// mid-run is safe and resumable. async function migrateNoteIds(): Promise { const legacy = await pool().query<{ id: string }>( - `SELECT id FROM notes WHERE id !~ '^[0-9a-f]{8}$'`, + `SELECT id FROM notes WHERE id !~ '^([0-9a-f]{8}|[0-9a-f]{32})$'`, ); if (legacy.rows.length === 0) return; const used = new Set( @@ -204,7 +199,12 @@ async function migrateNoteIds(): Promise { export function ready(): Promise { if (!global.__keepReady) { - global.__keepReady = bootstrap().then(() => migrateNoteIds()); + const attempt = bootstrap().then(() => migrateNoteIds()); + const guarded = attempt.catch((error) => { + if (global.__keepReady === guarded) global.__keepReady = undefined; + throw error; + }); + global.__keepReady = guarded; } return global.__keepReady; } @@ -245,8 +245,7 @@ export function rowToNote(r: NoteRow) { }; } -// 8 lowercase hex chars from a UUID v4 — 32 bits, and parseInt(id, 16) maps it -// to a single integer (MAC-address style, no separators). +// UUID v4 without separators: 128 bits of URL-safe lowercase hex. export function newId(): string { - return crypto.randomUUID().replace(/-/g, "").slice(0, 8); + return crypto.randomUUID().replace(/-/g, ""); } diff --git a/lib/email.ts b/lib/email.ts index a28ac94..58deb6f 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -22,6 +22,7 @@ export async function sendVerificationEmail(to: string, verifyUrl: string): Prom log.error("RESEND_API_KEY unset — cannot send verification email", { to: maskEmail(to), }); + throw new Error("Email delivery is not configured"); } return; } diff --git a/lib/noteLimits.ts b/lib/noteLimits.ts index c63f807..c728b88 100644 --- a/lib/noteLimits.ts +++ b/lib/noteLimits.ts @@ -1,6 +1,8 @@ // Abuse caps shared by the create and update note routes. Body and tags are // otherwise unbounded, which is a storage-exhaustion vector. export const MAX_NOTE_BODY = 256 * 1024; // 256 KB — generous for a single note +export const MAX_NOTE_TITLE = 120; +export const MAX_NOTE_SUMMARY = 500; export const MAX_TAGS = 50; export const MAX_TAG_LEN = 64; diff --git a/lib/offlineDb.ts b/lib/offlineDb.ts index 9a60886..07b7cfc 100644 --- a/lib/offlineDb.ts +++ b/lib/offlineDb.ts @@ -1,82 +1,125 @@ "use client"; -import { openDB, type IDBPDatabase } from "idb"; +import { deleteDB, openDB, type IDBPDatabase } from "idb"; import { Note } from "./types"; -const DB_NAME = "keep-offline"; +const DB_PREFIX = "keep-offline-v2"; +const LEGACY_DB_NAME = "keep-offline"; const DB_VERSION = 1; -interface PendingOp { +export type PendingOp = { id: string; type: "create" | "update" | "delete"; noteId: string; payload?: Partial; createdAt: number; +}; + +const dbPromises = new Map>(); +let legacyMigrationStarted = false; + +function databaseName(ownerId: string) { + return `${DB_PREFIX}:${encodeURIComponent(ownerId)}`; } -let dbPromise: Promise | null = null; +// The pre-per-owner "keep-offline" DB may still hold un-replayed pending ops +// (edits/deletes made offline that never reached the server). Move them into +// the first per-owner DB that opens so they replay, then drop the legacy DB. +// Cached notes are intentionally not migrated — they are just copies of server +// rows a refresh() will repopulate, and the legacy DB wasn't owner-scoped, so +// seeding them could surface a previous account's notes on a shared device. +async function migrateLegacyDatabase(target: IDBPDatabase) { + if (legacyMigrationStarted) return; + legacyMigrationStarted = true; + let legacy: IDBPDatabase | null = null; + try { + legacy = await openDB(LEGACY_DB_NAME, DB_VERSION); + if (legacy.objectStoreNames.contains("pending")) { + const ops = await legacy.getAll("pending"); + if (ops.length > 0) { + const tx = target.transaction("pending", "readwrite"); + for (const op of ops) await tx.store.put(op); + await tx.done; + } + } + } catch { + /* no legacy DB, or it was unreadable — nothing to migrate */ + } finally { + legacy?.close(); + await deleteDB(LEGACY_DB_NAME).catch(() => {}); + } +} -function getDb() { - if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { +function getDb(ownerId: string) { + const name = databaseName(ownerId); + let promise = dbPromises.get(name); + if (!promise) { + promise = openDB(name, DB_VERSION, { upgrade(db) { - if (!db.objectStoreNames.contains("notes")) { - db.createObjectStore("notes", { keyPath: "id" }); - } - if (!db.objectStoreNames.contains("pending")) { - const store = db.createObjectStore("pending", { keyPath: "id" }); - store.createIndex("createdAt", "createdAt"); - } + db.createObjectStore("notes", { keyPath: "id" }); + const pending = db.createObjectStore("pending", { keyPath: "id" }); + pending.createIndex("createdAt", "createdAt"); }, + }).then(async (db) => { + await migrateLegacyDatabase(db); + return db; }); + dbPromises.set(name, promise); } - return dbPromise; + return promise; } -export async function cacheNotes(notes: Note[]) { - const db = await getDb(); +export async function cacheNotes(ownerId: string, notes: Note[]) { + const db = await getDb(ownerId); const tx = db.transaction("notes", "readwrite"); const store = tx.objectStore("notes"); await store.clear(); - for (const note of notes) { - await store.put(note); - } + for (const note of notes) await store.put(note); await tx.done; } -export async function getCachedNotes(): Promise { - const db = await getDb(); - return db.getAll("notes"); +export async function getCachedNotes(ownerId: string): Promise { + return (await getDb(ownerId)).getAll("notes"); +} + +export async function cacheNote(ownerId: string, note: Note) { + await (await getDb(ownerId)).put("notes", note); } -export async function cacheNote(note: Note) { - const db = await getDb(); - await db.put("notes", note); +export async function removeCachedNote(ownerId: string, id: string) { + await (await getDb(ownerId)).delete("notes", id); } -export async function removeCachedNote(id: string) { - const db = await getDb(); - await db.delete("notes", id); +// Replay reads ops by the createdAt index, and IndexedDB breaks ties on the +// random id suffix — so two ops stamped in the same millisecond (e.g. a create +// and the update for text typed during it) could replay out of order, PATCHing +// a note the server hasn't created yet. Hand out strictly increasing stamps so +// insertion order is preserved. +let lastStamp = 0; +function nextStamp() { + const now = Date.now(); + lastStamp = now > lastStamp ? now : lastStamp + 1; + return lastStamp; } -export async function addPendingOp(op: Omit) { - const db = await getDb(); +export async function addPendingOp( + ownerId: string, + op: Omit, +) { + const stamp = nextStamp(); const entry: PendingOp = { ...op, - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - createdAt: Date.now(), + id: `${stamp}-${crypto.randomUUID()}`, + createdAt: stamp, }; - await db.put("pending", entry); + await (await getDb(ownerId)).put("pending", entry); + return entry; } -export async function getPendingOps(): Promise { - const db = await getDb(); - return db.getAllFromIndex("pending", "createdAt"); +export async function getPendingOps(ownerId: string): Promise { + return (await getDb(ownerId)).getAllFromIndex("pending", "createdAt"); } -export async function clearPendingOps() { - const db = await getDb(); - const tx = db.transaction("pending", "readwrite"); - await tx.objectStore("pending").clear(); - await tx.done; +export async function removePendingOp(ownerId: string, id: string) { + await (await getDb(ownerId)).delete("pending", id); } diff --git a/lib/safeRedirect.ts b/lib/safeRedirect.ts index a611eed..3dede5e 100644 --- a/lib/safeRedirect.ts +++ b/lib/safeRedirect.ts @@ -3,6 +3,26 @@ * Rejects absolute URLs and protocol-relative "//evil.com" so a ?from= * parameter can't redirect an authenticated user off-site. */ -export function safeRedirect(from: string | undefined | null): string { - return from && from.startsWith("/") && !from.startsWith("//") ? from : "/"; +export function safeRedirect(from: unknown): string { + if (typeof from !== "string" || !from.startsWith("/") || from.startsWith("//")) { + return "/"; + } + if (from.includes("\\") || /%5c/i.test(from) || /[\u0000-\u001f\u007f]/.test(from)) { + return "/"; + } + try { + const base = new URL("https://keep.invalid"); + const resolved = new URL(from, base); + if (resolved.origin !== base.origin) return "/"; + const path = `${resolved.pathname}${resolved.search}${resolved.hash}`; + // A dot-segment input like "/..//evil.com" resolves to pathname "//evil.com" + // — same origin here, but a protocol-relative URL once the browser follows + // the redirect. Reject any result that isn't a single-slash-rooted path. + if (!path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) { + return "/"; + } + return path; + } catch { + return "/"; + } } diff --git a/lib/shareToken.ts b/lib/shareToken.ts new file mode 100644 index 0000000..1f3a558 --- /dev/null +++ b/lib/shareToken.ts @@ -0,0 +1,7 @@ +import { randomBytes } from "crypto"; + +// Share links are bearer credentials. Use 128 bits so they remain unguessable +// even with many active links; existing shorter tokens continue to resolve. +export function newShareToken() { + return randomBytes(16).toString("base64url"); +} diff --git a/lib/useEncryption.ts b/lib/useEncryption.ts deleted file mode 100644 index 2bf815f..0000000 --- a/lib/useEncryption.ts +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -// Manages the in-memory AES-GCM key for E2E note encryption. -// -// The key lives only in the module-level variable below — it is never -// written to localStorage, sessionStorage, or any other persistent store. -// A page refresh clears it, which is intentional: the user must re-enter -// their passphrase each session to unlock their notes. -import { useCallback, useEffect, useState } from "react"; -import { - deriveKey, - encryptBody, - decryptBody, - generateSalt, - isEncrypted, -} from "./crypto"; - -// Module-level so the key survives React re-renders but not page reloads. -let _key: CryptoKey | null = null; - -export type EncStatus = - | "loading" // fetching salt from server - | "disabled" // no salt → encryption not configured - | "locked" // salt exists but passphrase not entered this session - | "unlocked"; // key is in memory, notes can be decrypted - -export function useEncryption() { - const [status, setStatus] = useState("loading"); - const [salt, setSalt] = useState(null); - - useEffect(() => { - fetch("/api/enc/salt") - .then((r) => (r.ok ? r.json() : null)) - .then((data: { salt: string | null } | null) => { - const s = data?.salt ?? null; - setSalt(s); - if (!s) { - setStatus("disabled"); - } else if (_key) { - setStatus("unlocked"); - } else { - setStatus("locked"); - } - }) - .catch(() => setStatus("disabled")); - }, []); - - const unlock = useCallback( - async (passphrase: string): Promise => { - if (!salt) return false; - try { - _key = await deriveKey(passphrase, salt); - setStatus("unlocked"); - return true; - } catch { - return false; - } - }, - [salt], - ); - - const setupEncryption = useCallback(async (passphrase: string): Promise => { - const res = await fetch("/api/enc/salt", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ salt: generateSalt() }), - }); - if (!res.ok) throw new Error("Failed to save salt"); - // Derive from whatever salt the server actually stored (may pre-exist). - const data = (await res.json()) as { salt?: string }; - const effectiveSalt = data.salt; - if (!effectiveSalt) throw new Error("No salt returned"); - _key = await deriveKey(passphrase, effectiveSalt); - setSalt(effectiveSalt); - setStatus("unlocked"); - }, []); - - const disableEncryption = useCallback(async (): Promise => { - await fetch("/api/enc/salt", { method: "DELETE" }); - _key = null; - setSalt(null); - setStatus("disabled"); - }, []); - - const lock = useCallback(() => { - _key = null; - if (salt) setStatus("locked"); - }, [salt]); - - const encrypt = useCallback( - async (plaintext: string): Promise => { - if (!_key) return plaintext; - return encryptBody(_key, plaintext); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [status], - ); - - const decrypt = useCallback( - async (body: string): Promise => { - if (!_key || !isEncrypted(body)) return body; - try { - return await decryptBody(_key, body); - } catch { - return "[Decryption failed — wrong passphrase?]"; - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [status], - ); - - return { status, unlock, setupEncryption, disableEncryption, lock, encrypt, decrypt }; -} diff --git a/lib/useNotes.ts b/lib/useNotes.ts index 8cf5c4c..6ea40ca 100644 --- a/lib/useNotes.ts +++ b/lib/useNotes.ts @@ -10,13 +10,13 @@ import { removeCachedNote, addPendingOp, getPendingOps, - clearPendingOps, + removePendingOp, } from "./offlineDb"; const GUEST_NOTES_KEY = "keep.guestNotes.v1"; function localId() { - return crypto.randomUUID().replace(/-/g, "").slice(0, 8); + return crypto.randomUUID().replace(/-/g, ""); } function readGuestNotes(): Note[] { @@ -79,11 +79,27 @@ async function api( } catch { /* ignore */ } - throw new Error(msg); + throw new ApiRequestError(msg, res.status); } return res.json() as Promise; } +class ApiRequestError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +function isRetryable(error: unknown) { + return ( + !(error instanceof ApiRequestError) || + error.status === 408 || + error.status === 425 || + error.status === 429 || + error.status >= 500 + ); +} + function firstLine(body: string) { return body.split(/\r?\n/, 1)[0]?.trim() ?? ""; } @@ -102,15 +118,38 @@ async function metaForBody(body: string): Promise<{ title: string; summary: stri export type SyncStatus = "idle" | "syncing" | "saved" | "error" | "offline"; -export function useNotes() { +export function useNotes(ownerId: string | null) { const [notes, setNotes] = useState([]); const [hydrated, setHydrated] = useState(false); - const [isGuest, setIsGuest] = useState(false); + const isGuest = ownerId === null; const [localNoteIds, setLocalNoteIds] = useState>(() => new Set()); const [error, setError] = useState(null); const [syncStatus, setSyncStatus] = useState("idle"); + const [replayTick, setReplayTick] = useState(0); const inflightRef = useRef(0); - const savedTimerRef = useRef>(); + const savedTimerRef = useRef | null>(null); + const retryTimerRef = useRef | null>(null); + const notesRef = useRef([]); + const mutationRevisionRef = useRef(new Map()); + const saveChainsRef = useRef(new Map>()); + const queuedNoteIdsRef = useRef(new Set()); + const ownerRef = useRef(ownerId); + ownerRef.current = ownerId; + + useEffect(() => { + notesRef.current = notes; + }, [notes]); + + useEffect(() => { + if (!ownerId) { + queuedNoteIdsRef.current.clear(); + return; + } + void getPendingOps(ownerId).then((ops) => { + if (ownerRef.current !== ownerId) return; + queuedNoteIdsRef.current = new Set(ops.map((op) => op.noteId)); + }); + }, [ownerId]); useEffect(() => { function goOffline() { setSyncStatus("offline"); } @@ -124,7 +163,27 @@ export function useNotes() { }; }, []); - function trackSync(promise: Promise): Promise { + const requestReplay = useCallback((delayMs = 0) => { + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + if (delayMs === 0) { + setReplayTick((value) => value + 1); + return; + } + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + setReplayTick((value) => value + 1); + }, delayMs); + }, []); + + useEffect( + () => () => { + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }, + [], + ); + + const trackSync = useCallback((promise: Promise): Promise => { inflightRef.current++; setSyncStatus("syncing"); return promise.then( @@ -132,7 +191,7 @@ export function useNotes() { inflightRef.current--; if (inflightRef.current === 0) { setSyncStatus("saved"); - clearTimeout(savedTimerRef.current); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); savedTimerRef.current = setTimeout(() => setSyncStatus("idle"), 2000); } return result; @@ -143,32 +202,65 @@ export function useNotes() { throw err; }, ); - } + }, []); + + const enqueueSave = useCallback((id: string, task: () => Promise) => { + const previous = saveChainsRef.current.get(id) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(task); + saveChainsRef.current.set(id, next); + const cleanup = () => { + if (saveChainsRef.current.get(id) === next) saveChainsRef.current.delete(id); + }; + void next.then(cleanup, cleanup); + return next; + }, []); const refresh = useCallback(async () => { + if (!ownerId) { + const guestNotes = readGuestNotes(); + setLocalNoteIds(new Set(guestNotes.map((note) => note.id))); + notesRef.current = guestNotes; + setNotes(guestNotes); + setError(null); + setHydrated(true); + return; + } + try { const data = await api<{ notes: Note[] }>("/api/notes"); + if (ownerRef.current !== ownerId) return; const guestNotes = readGuestNotes(); + cacheNotes(ownerId, data.notes).catch(() => {}); if (guestNotes.length > 0) { + const loaded = [...guestNotes, ...data.notes]; setLocalNoteIds(new Set(guestNotes.map((note) => note.id))); - setNotes([...guestNotes, ...data.notes]); + notesRef.current = loaded; + setNotes(loaded); } else { setLocalNoteIds(new Set()); + notesRef.current = data.notes; setNotes(data.notes); - cacheNotes(data.notes).catch(() => {}); } - setIsGuest(false); setError(null); } catch (e) { - if (e instanceof Error && e.message === "Unauthorized") { + if (ownerRef.current !== ownerId) return; + if (e instanceof ApiRequestError && e.status === 401) { + // Session expired mid-tab: keep showing the cached synced notes (and any + // unsynced guest notes) read-only rather than blanking the list — the + // cache holds a full copy the user can still read until they re-auth. const guestNotes = readGuestNotes(); + const cached = await getCachedNotes(ownerId).catch(() => []); + if (ownerRef.current !== ownerId) return; + const merged = guestNotes.length > 0 ? [...guestNotes, ...cached] : cached; setLocalNoteIds(new Set(guestNotes.map((note) => note.id))); - setNotes(guestNotes); - setIsGuest(true); - setError(null); + notesRef.current = merged; + setNotes(merged); + setError("Your session expired. Sign in again to sync changes."); } else { - const cached = await getCachedNotes().catch(() => []); + const cached = await getCachedNotes(ownerId).catch(() => []); + if (ownerRef.current !== ownerId) return; if (cached.length > 0) { + notesRef.current = cached; setNotes(cached); setError(null); } else { @@ -176,21 +268,33 @@ export function useNotes() { } } } finally { - setHydrated(true); + if (ownerRef.current === ownerId) setHydrated(true); } - }, []); + }, [ownerId]); useEffect(() => { - getCachedNotes() + setHydrated(false); + notesRef.current = []; + setNotes([]); + setLocalNoteIds(new Set()); + if (!ownerId) { + void refresh(); + return; + } + getCachedNotes(ownerId) .then((cached) => { + if (ownerRef.current !== ownerId) return; if (cached.length > 0) { + notesRef.current = cached; setNotes(cached); setHydrated(true); } }) .catch(() => {}) - .finally(() => refresh()); - }, [refresh]); + .finally(() => { + if (ownerRef.current === ownerId) void refresh(); + }); + }, [ownerId, refresh]); useEffect(() => { if (!hydrated) return; @@ -202,22 +306,68 @@ export function useNotes() { const syncingRef = useRef(false); useEffect(() => { async function replayPending() { - if (syncingRef.current || isGuest) return; + if (syncingRef.current || !ownerId) return; syncingRef.current = true; + let changed = false; + let drained = true; try { - const ops = await getPendingOps(); - for (const op of ops) { - try { - if (op.type === "update" && op.payload) { - await api(`/api/notes/${op.noteId}`, { method: "PATCH", json: op.payload }); - } else if (op.type === "delete") { - await api(`/api/notes/${op.noteId}`, { method: "DELETE" }); + while (true) { + if (ownerRef.current !== ownerId) return; + const ops = await getPendingOps(ownerId); + queuedNoteIdsRef.current = new Set(ops.map((op) => op.noteId)); + if (ops.length === 0) break; + + let blocked = false; + for (const op of ops) { + try { + await enqueueSave(op.noteId, async () => { + if (op.type === "create" && op.payload) { + await api("/api/notes", { method: "POST", json: op.payload }); + } else if (op.type === "update" && op.payload) { + await api(`/api/notes/${op.noteId}`, { method: "PATCH", json: op.payload }); + } else if (op.type === "delete") { + await api(`/api/notes/${op.noteId}`, { method: "DELETE" }); + } + }); + await removePendingOp(ownerId, op.id); + changed = true; + } catch (error) { + const status = error instanceof ApiRequestError ? error.status : 0; + if (status === 401) { + // Session expired: keep the op and stop; a refresh after + // re-auth will retry it. + setError("Your session expired. Sign in again to sync queued changes."); + blocked = true; + drained = false; + break; + } + if (isRetryable(error)) { + // Transient (network/408/429/5xx): keep the op, retry shortly. + blocked = true; + drained = false; + requestReplay(5_000); + break; + } + // Permanent rejection (note deleted elsewhere -> 404, or a + // 400/409/413 the server will never accept). Drop it: leaving a + // dead op at the head of the queue would wedge every later + // change behind it forever. + await removePendingOp(ownerId, op.id).catch(() => {}); + changed = true; } - } catch { /* individual op failed, will retry next time */ } + } + if (blocked) break; } - if (ops.length > 0) { - await clearPendingOps(); - refresh(); + const remaining = await getPendingOps(ownerId); + queuedNoteIdsRef.current = new Set(remaining.map((op) => op.noteId)); + if (changed && drained && remaining.length === 0) await refresh(); + // replayPending drives ops through bare api() rather than trackSync, so + // a status left at "error"/"syncing"/"offline" by the queued-op paths + // never clears on a clean drain. Settle it here once the queue is empty. + if (drained && remaining.length === 0 && inflightRef.current === 0 && navigator.onLine) { + setSyncStatus((prev) => + prev === "error" || prev === "syncing" || prev === "offline" ? "idle" : prev, + ); } } finally { syncingRef.current = false; @@ -227,137 +377,276 @@ export function useNotes() { window.addEventListener("online", onOnline); if (hydrated && navigator.onLine) replayPending(); return () => window.removeEventListener("online", onOnline); - }, [hydrated, isGuest, refresh]); + }, [enqueueSave, hydrated, ownerId, refresh, replayTick, requestReplay]); const create = useCallback(async (partial: Partial) => { const body = partial.body ?? ""; const meta = partial.title ? { title: partial.title, summary: partial.summary ?? "" } : await metaForBody(body); + const now = Date.now(); + const note: Note = { + id: localId(), + title: meta.title, + summary: meta.summary || null, + color: partial.color ?? null, + body, + pinned: partial.pinned ?? false, + archived: partial.archived ?? false, + trashed: false, + markdown: partial.markdown ?? false, + highlight: partial.highlight ?? false, + tags: partial.tags ?? [], + shareToken: null, + createdAt: now, + updatedAt: now, + }; + if (isGuest) { - const now = Date.now(); - const note: Note = { - id: localId(), - title: meta.title, - summary: meta.summary || null, - body, - pinned: partial.pinned ?? false, - archived: partial.archived ?? false, - trashed: false, - markdown: partial.markdown ?? false, - highlight: partial.highlight ?? false, - tags: partial.tags ?? [], - shareToken: null, - createdAt: now, - updatedAt: now, - }; setLocalNoteIds((prev) => new Set(prev).add(note.id)); - setNotes((prev) => [note, ...prev]); + const next = [note, ...notesRef.current]; + notesRef.current = next; + setNotes(next); return note; } + if (!ownerId) return null; + const next = [note, ...notesRef.current]; + notesRef.current = next; + setNotes(next); + cacheNote(ownerId, note).catch(() => {}); + try { const data = await trackSync(api<{ note: Note }>("/api/notes", { method: "POST", json: { + id: note.id, title: meta.title, summary: meta.summary, + color: note.color, body, - pinned: partial.pinned ?? false, - archived: partial.archived ?? false, + pinned: note.pinned, + archived: note.archived, trashed: false, - markdown: partial.markdown ?? false, - highlight: partial.highlight ?? false, - tags: partial.tags ?? [], + markdown: note.markdown, + highlight: note.highlight, + tags: note.tags, }, })); - setNotes((prev) => [data.note, ...prev]); + // Upsert, don't map: a refresh() fired by replay/reconnect can overwrite + // notesRef with a server list that predates this POST, and a plain map + // would then find no match and silently drop the note the user just made. + const current = notesRef.current; + const saved = current.some((item) => item.id === note.id) + ? current.map((item) => (item.id === note.id ? data.note : item)) + : [data.note, ...current]; + notesRef.current = saved; + setNotes(saved); + cacheNote(ownerId, data.note).catch(() => {}); return data.note; } catch (e) { + if (isRetryable(e)) { + await addPendingOp(ownerId, { + type: "create", + noteId: note.id, + payload: note, + }) + .then(() => { + queuedNoteIdsRef.current.add(note.id); + requestReplay(); + }) + .catch(() => { + setError("The note could not be queued for sync."); + }); + setSyncStatus(navigator.onLine ? "error" : "offline"); + return note; + } setError(e instanceof Error ? e.message : "Failed to create"); + const reverted = notesRef.current.filter((item) => item.id !== note.id); + notesRef.current = reverted; + setNotes(reverted); + removeCachedNote(ownerId, note.id).catch(() => {}); return null; } - }, [isGuest]); + }, [isGuest, ownerId, requestReplay, trackSync]); + + const update = useCallback((id: string, patch: Partial): Promise => { + const current = notesRef.current.find((note) => note.id === id); + if (!current) return Promise.resolve(); - const update = useCallback(async (id: string, patch: Partial) => { - const current = notes.find((note) => note.id === id); const bodyChanged = - patch.body !== undefined && patch.body !== (current?.body ?? ""); + patch.body !== undefined && patch.body !== current.body; // Only regenerate title/summary when the lead line changes (or there's no // title yet) — avoids a model call on every keystroke-batch autosave. const needsMeta = bodyChanged && patch.body !== undefined && patch.title === undefined && - (firstLine(patch.body) !== firstLine(current?.body ?? "") || - !current?.title?.trim()); - let nextPatch = patch; - if (needsMeta && patch.body !== undefined) { - const meta = await metaForBody(patch.body); - nextPatch = { - ...patch, - title: meta.title, - ...(meta.summary ? { summary: meta.summary } : {}), - }; - } - setNotes((prev) => - prev.map((n) => - n.id === id ? { ...n, ...nextPatch, updatedAt: Date.now() } : n, - ), + (firstLine(patch.body) !== firstLine(current.body) || !current.title.trim()); + const revision = (mutationRevisionRef.current.get(id) ?? 0) + 1; + mutationRevisionRef.current.set(id, revision); + const updatedAt = Date.now(); + const optimistic = { ...current, ...patch, updatedAt }; + const optimisticNotes = notesRef.current.map((note) => + note.id === id ? optimistic : note, ); - if (isGuest || localNoteIds.has(id)) return; + notesRef.current = optimisticNotes; + setNotes(optimisticNotes); + + const applyGeneratedMeta = (generated: { title: string; summary: string }) => { + if (mutationRevisionRef.current.get(id) !== revision) return; + const next = notesRef.current.map((note) => + note.id === id + ? { + ...note, + title: generated.title, + ...(generated.summary ? { summary: generated.summary } : {}), + } + : note, + ); + notesRef.current = next; + setNotes(next); + }; - const updated = current ? { ...current, ...nextPatch, updatedAt: Date.now() } : null; - if (updated) cacheNote(updated).catch(() => {}); + if (isGuest || localNoteIds.has(id)) { + if (!needsMeta || patch.body === undefined) return Promise.resolve(); + return metaForBody(patch.body).then(applyGeneratedMeta); + } + if (!ownerId) return Promise.resolve(); - try { - const data = await trackSync(api<{ note: Note }>(`/api/notes/${id}`, { - method: "PATCH", - json: nextPatch, - })); - setNotes((prev) => prev.map((n) => (n.id === id ? data.note : n))); - cacheNote(data.note).catch(() => {}); - } catch (e) { - if (!navigator.onLine) { - addPendingOp({ type: "update", noteId: id, payload: nextPatch }).catch(() => {}); - } else { - setError(e instanceof Error ? e.message : "Failed to save"); - refresh(); + cacheNote(ownerId, optimistic).catch(() => {}); + + return enqueueSave(id, async () => { + let nextPatch = patch; + if (needsMeta && patch.body !== undefined) { + const meta = await metaForBody(patch.body); + nextPatch = { + ...patch, + title: meta.title, + ...(meta.summary ? { summary: meta.summary } : {}), + }; + applyGeneratedMeta(meta); } - } - }, [isGuest, localNoteIds, notes, refresh]); + + const alreadyQueued = + queuedNoteIdsRef.current.has(id) || + (await getPendingOps(ownerId).catch(() => [])).some((op) => op.noteId === id); + if (alreadyQueued) { + queuedNoteIdsRef.current.add(id); + await addPendingOp(ownerId, { + type: "update", + noteId: id, + payload: nextPatch, + }) + .then(() => requestReplay()) + .catch(() => setError("The change could not be queued for sync.")); + setSyncStatus(navigator.onLine ? "syncing" : "offline"); + return; + } + + try { + const data = await trackSync(api<{ note: Note }>(`/api/notes/${id}`, { + method: "PATCH", + json: nextPatch, + })); + if (mutationRevisionRef.current.get(id) === revision) { + const next = notesRef.current.map((note) => + note.id === id ? data.note : note, + ); + notesRef.current = next; + setNotes(next); + cacheNote(ownerId, data.note).catch(() => {}); + } + setError(null); + } catch (error) { + if (isRetryable(error)) { + await addPendingOp(ownerId, { + type: "update", + noteId: id, + payload: nextPatch, + }) + .then(() => { + queuedNoteIdsRef.current.add(id); + requestReplay(); + }) + .catch(() => setError("The change could not be queued for sync.")); + setSyncStatus(navigator.onLine ? "error" : "offline"); + } else { + setError(error instanceof Error ? error.message : "Failed to save"); + if (mutationRevisionRef.current.get(id) === revision) await refresh(); + } + } + }); + }, [enqueueSave, isGuest, localNoteIds, ownerId, refresh, requestReplay, trackSync]); const trash = useCallback(async (id: string) => { - const n = notes.find((note) => note.id === id); + const n = notesRef.current.find((note) => note.id === id); if (!n) return; - update(id, { trashed: true, pinned: false, archived: false }); - }, [notes, update]); + await update(id, { trashed: true, pinned: false, archived: false }); + }, [update]); const restore = useCallback(async (id: string) => { - const n = notes.find((note) => note.id === id); + const n = notesRef.current.find((note) => note.id === id); if (!n) return; - update(id, { trashed: false, archived: false }); - }, [notes, update]); - - const remove = useCallback(async (id: string) => { - const prev = notes; - setNotes((p) => p.filter((n) => n.id !== id)); + await update(id, { trashed: false, archived: false }); + }, [update]); + + const remove = useCallback((id: string): Promise => { + const removed = notesRef.current.find((note) => note.id === id); + if (!removed) return Promise.resolve(); + const revision = (mutationRevisionRef.current.get(id) ?? 0) + 1; + mutationRevisionRef.current.set(id, revision); + const next = notesRef.current.filter((note) => note.id !== id); + notesRef.current = next; + setNotes(next); if (isGuest || localNoteIds.has(id)) { setLocalNoteIds((ids) => { - const next = new Set(ids); - next.delete(id); - return next; + const remaining = new Set(ids); + remaining.delete(id); + return remaining; }); - return; + return Promise.resolve(); } - - try { - await trackSync(api(`/api/notes/${id}`, { method: "DELETE" })); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to delete"); - setNotes(prev); - } - }, [isGuest, localNoteIds, notes]); + if (!ownerId) return Promise.resolve(); + removeCachedNote(ownerId, id).catch(() => {}); + + return enqueueSave(id, async () => { + const alreadyQueued = + queuedNoteIdsRef.current.has(id) || + (await getPendingOps(ownerId).catch(() => [])).some((op) => op.noteId === id); + if (alreadyQueued) { + queuedNoteIdsRef.current.add(id); + await addPendingOp(ownerId, { type: "delete", noteId: id }).catch(() => + setError("The deletion could not be queued for sync."), + ); + requestReplay(); + setSyncStatus(navigator.onLine ? "syncing" : "offline"); + return; + } + try { + await trackSync(api(`/api/notes/${id}`, { method: "DELETE" })); + setError(null); + } catch (error) { + if (isRetryable(error)) { + await addPendingOp(ownerId, { type: "delete", noteId: id }) + .then(() => { + queuedNoteIdsRef.current.add(id); + requestReplay(); + }) + .catch(() => setError("The deletion could not be queued for sync.")); + setSyncStatus(navigator.onLine ? "error" : "offline"); + } else { + setError(error instanceof Error ? error.message : "Failed to delete"); + if (mutationRevisionRef.current.get(id) === revision) { + const restored = [removed, ...notesRef.current]; + notesRef.current = restored; + setNotes(restored); + cacheNote(ownerId, removed).catch(() => {}); + } + } + } + }); + }, [enqueueSave, isGuest, localNoteIds, ownerId, requestReplay, trackSync]); const importKeepFile = useCallback(async (file: File) => { if (isGuest) { @@ -369,10 +658,10 @@ export function useNotes() { const now = Date.now(); const importable = imported.filter((note) => !note.trashed); const guestNotes: Note[] = []; - for (const [index, note] of importable.entries()) { + for (const note of importable) { const meta = await metaForBody(note.body); guestNotes.push({ - id: `${localId()}${index.toString(36).toUpperCase()}`, + id: localId(), title: meta.title, summary: meta.summary || null, body: note.body, @@ -392,7 +681,9 @@ export function useNotes() { for (const note of guestNotes) next.add(note.id); return next; }); - setNotes((prev) => [...guestNotes, ...prev]); + const next = [...guestNotes, ...notesRef.current]; + notesRef.current = next; + setNotes(next); return { imported: guestNotes.length, skipped: skipped + imported.length - importable.length, @@ -420,8 +711,8 @@ export function useNotes() { }, [isGuest, refresh]); // Imports this app's own exports: a single .txt/.md, or a .zip of them - // (one text per file). Routes through create() so guest/server + encryption - // are handled the same as any new text. + // (one text per file). Routes through create() so guest/server storage is + // handled the same as any new text. const importTextFiles = useCallback( async (file: File) => { const bodies: string[] = []; @@ -452,6 +743,7 @@ export function useNotes() { ); const saveLocalNotes = useCallback(async () => { + if (!ownerId) return { saved: 0 }; const localNotes = notes.filter( (note) => localNoteIds.has(note.id) && !note.trashed, ); @@ -462,7 +754,10 @@ export function useNotes() { api<{ note: Note }>("/api/notes", { method: "POST", json: { - title: "", + id: note.id, + title: note.title, + summary: note.summary ?? null, + color: note.color ?? null, body: note.body, pinned: note.pinned, archived: note.archived, @@ -476,34 +771,37 @@ export function useNotes() { ); clearGuestNotes(); setLocalNoteIds(new Set()); - setNotes((prev) => [ + const next = [ ...imported.map((item) => item.note), - ...prev.filter((note) => !localNoteIds.has(note.id)), - ]); + ...notesRef.current.filter((note) => !localNoteIds.has(note.id)), + ]; + notesRef.current = next; + setNotes(next); + cacheNotes(ownerId, next).catch(() => {}); return { saved: imported.length }; - }, [localNoteIds, notes]); + }, [localNoteIds, notes, ownerId]); const togglePin = useCallback( (id: string) => { - const n = notes.find((x) => x.id === id); + const n = notesRef.current.find((note) => note.id === id); if (!n) return; - update(id, { pinned: !n.pinned }); + void update(id, { pinned: !n.pinned }); }, - [notes, update], + [update], ); const toggleArchive = useCallback( (id: string) => { - const n = notes.find((x) => x.id === id); + const n = notesRef.current.find((note) => note.id === id); if (!n) return; const becomingArchived = !n.archived; - update(id, { + void update(id, { archived: becomingArchived, pinned: becomingArchived ? false : n.pinned, trashed: false, }); }, - [notes, update], + [update], ); const share = useCallback( @@ -513,7 +811,13 @@ export function useNotes() { const data = await api<{ note: Note }>(`/api/notes/${id}/share`, { method: "POST", }); - setNotes((prev) => prev.map((n) => (n.id === id ? data.note : n))); + const next = notesRef.current.map((note) => + note.id === id + ? { ...note, shareToken: data.note.shareToken, updatedAt: Math.max(note.updatedAt, data.note.updatedAt) } + : note, + ); + notesRef.current = next; + setNotes(next); return data.note.shareToken; } catch (e) { setError(e instanceof Error ? e.message : "Failed to share"); @@ -530,7 +834,13 @@ export function useNotes() { const data = await api<{ note: Note }>(`/api/notes/${id}/share`, { method: "DELETE", }); - setNotes((prev) => prev.map((n) => (n.id === id ? data.note : n))); + const next = notesRef.current.map((note) => + note.id === id + ? { ...note, shareToken: data.note.shareToken, updatedAt: Math.max(note.updatedAt, data.note.updatedAt) } + : note, + ); + notesRef.current = next; + setNotes(next); } catch (e) { setError(e instanceof Error ? e.message : "Failed to unshare"); } @@ -546,7 +856,13 @@ export function useNotes() { method: "PUT", json: { token }, }); - setNotes((prev) => prev.map((n) => (n.id === id ? data.note : n))); + const next = notesRef.current.map((note) => + note.id === id + ? { ...note, shareToken: data.note.shareToken, updatedAt: Math.max(note.updatedAt, data.note.updatedAt) } + : note, + ); + notesRef.current = next; + setNotes(next); return data.note.shareToken; }, [], diff --git a/next-env.d.ts b/next-env.d.ts index 40c3d68..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index 9f337d9..af36170 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,5 @@ // Low-risk hardening headers applied to every response. The Content-Security- -// Policy is set per-request in middleware.ts instead (it needs a fresh nonce +// Policy is set per-request in proxy.ts instead (it needs a fresh nonce // for the inline bootstrap scripts, which a static header can't carry). HSTS is // handled by Caddy. const securityHeaders = [ diff --git a/package-lock.json b/package-lock.json index 9652935..b16fec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,19 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1069.0", "@tailwindcss/typography": "^0.5.19", - "@vercel/blob": "^2.4.0", + "@vercel/blob": "^2.6.1", "fuse.js": "^7.3.0", "geist": "^1.7.1", "idb": "^8.0.3", "jspdf": "^4.2.1", "jszip": "^3.10.1", "marked": "^18.0.5", - "next": "^14.2.35", + "next": "^16.2.10", "next-auth": "^5.0.0-beta.31", "pdfjs-dist": "^6.0.227", "pg": "^8.21.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shiki": "^4.1.0" @@ -32,9 +32,10 @@ "@testing-library/react": "^16.3.2", "@types/node": "^22.9.0", "@types/pg": "^8.20.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", "playwright-core": "^1.61.0", "postcss": "^8.4.49", @@ -762,14 +763,14 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, @@ -777,7 +778,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -785,9 +785,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -813,6 +813,520 @@ } } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1114,14 +1628,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1133,15 +1647,15 @@ } }, "node_modules/@next/env": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", - "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", + "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", - "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", + "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", "cpu": [ "arm64" ], @@ -1155,9 +1669,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", - "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", + "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", "cpu": [ "x64" ], @@ -1171,9 +1685,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", - "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", + "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", "cpu": [ "arm64" ], @@ -1190,9 +1704,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", - "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", + "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", "cpu": [ "arm64" ], @@ -1209,9 +1723,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", - "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", + "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", "cpu": [ "x64" ], @@ -1228,9 +1742,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", - "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", + "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", "cpu": [ "x64" ], @@ -1247,9 +1761,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", - "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", + "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", "cpu": [ "arm64" ], @@ -1262,26 +1776,10 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", - "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", - "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", + "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", "cpu": [ "x64" ], @@ -1342,9 +1840,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1361,9 +1859,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1378,9 +1876,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1395,9 +1893,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1412,9 +1910,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1429,9 +1927,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1446,9 +1944,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1466,9 +1964,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1486,9 +1984,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1506,9 +2004,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1526,9 +2024,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1546,9 +2044,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1566,9 +2064,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1583,9 +2081,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1593,18 +2091,29 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1619,9 +2128,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1869,20 +2378,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@tailwindcss/typography": { @@ -1987,9 +2489,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2099,12 +2601,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -2113,23 +2609,22 @@ "optional": true }, "node_modules/@types/react": { - "version": "18.3.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", - "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/trusted-types": { @@ -2152,11 +2647,12 @@ "license": "ISC" }, "node_modules/@vercel/blob": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.4.0.tgz", - "integrity": "sha512-ncQ8CRb6XoEAYJwjOTRGpACRT6h/AeY+/33gLyeVxG5BIes27OPm1jmqreF+JHjcTmGhClTP+kBpmyLfbV0xew==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.6.1.tgz", + "integrity": "sha512-KTJytw85j1XQBxjN5d6UXI7fIWNQe1jotn4nWN+0hePqLs+Qi1B3jHdQcSKFGF0m2rsy9uhPT6GOXMtHe3qNzg==", "license": "Apache-2.0", "dependencies": { + "@vercel/oidc": "^3.6.1", "async-retry": "^1.3.3", "is-buffer": "^2.0.5", "is-node-process": "^1.2.0", @@ -2167,6 +2663,51 @@ "node": ">=20.0.0" } }, + "node_modules/@vercel/cli-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.0.tgz", + "integrity": "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==", + "license": "Apache-2.0", + "dependencies": { + "xdg-app-paths": "5", + "zod": "4.1.11" + } + }, + "node_modules/@vercel/cli-exec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-1.0.0.tgz", + "integrity": "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==", + "license": "Apache-2.0", + "dependencies": { + "execa": "5.1.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.8.0.tgz", + "integrity": "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A==", + "license": "Apache-2.0", + "dependencies": { + "@vercel/cli-config": "0.2.0", + "@vercel/cli-exec": "1.0.0", + "jose": "^5.9.6" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vercel/oidc/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -2432,7 +2973,6 @@ "version": "2.10.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2515,17 +3055,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2721,6 +3250,20 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -2872,7 +3415,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2999,6 +3542,29 @@ "@types/estree": "^1.0.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3015,6 +3581,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3193,6 +3769,18 @@ "next": ">=13.2.0" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3205,12 +3793,6 @@ "node": ">=10.13.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -3333,6 +3915,15 @@ "node": ">=8.0.0" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/idb": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", @@ -3522,12 +4113,30 @@ "dev": true, "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3550,7 +4159,9 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/jsdom": { "version": "29.1.1", @@ -3607,9 +4218,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -3980,18 +4591,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -4322,6 +4921,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4907,6 +5512,15 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4953,41 +5567,41 @@ } }, "node_modules/next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", - "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", + "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", "license": "MIT", "dependencies": { - "@next/env": "14.2.35", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", + "@next/env": "16.2.10", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", "postcss": "8.4.31", - "styled-jsx": "5.1.1" + "styled-jsx": "5.1.6" }, "bin": { "next": "dist/bin/next" }, "engines": { - "node": ">=18.17.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.33", - "@next/swc-darwin-x64": "14.2.33", - "@next/swc-linux-arm64-gnu": "14.2.33", - "@next/swc-linux-arm64-musl": "14.2.33", - "@next/swc-linux-x64-gnu": "14.2.33", - "@next/swc-linux-x64-musl": "14.2.33", - "@next/swc-win32-arm64-msvc": "14.2.33", - "@next/swc-win32-ia32-msvc": "14.2.33", - "@next/swc-win32-x64-msvc": "14.2.33" + "@next/swc-darwin-arm64": "16.2.10", + "@next/swc-darwin-x64": "16.2.10", + "@next/swc-linux-arm64-gnu": "16.2.10", + "@next/swc-linux-arm64-musl": "16.2.10", + "@next/swc-linux-x64-gnu": "16.2.10", + "@next/swc-linux-x64-musl": "16.2.10", + "@next/swc-win32-arm64-msvc": "16.2.10", + "@next/swc-win32-x64-msvc": "16.2.10", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -4997,6 +5611,9 @@ "@playwright/test": { "optional": true }, + "babel-plugin-react-compiler": { + "optional": true + }, "sass": { "optional": true } @@ -5029,34 +5646,6 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-releases": { "version": "2.0.44", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", @@ -5073,6 +5662,18 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/oauth4webapi": { "version": "3.8.6", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", @@ -5111,6 +5712,21 @@ ], "license": "MIT" }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -5128,6 +5744,15 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/os-paths": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz", + "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==", + "license": "MIT", + "engines": { + "node": ">= 6.0" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5187,6 +5812,15 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -5358,9 +5992,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -5644,28 +6278,24 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.7" } }, "node_modules/react-is": { @@ -5911,13 +6541,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5927,21 +6557,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/run-parallel": { @@ -5987,12 +6617,22 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/setimmediate": { @@ -6001,6 +6641,72 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.1.0.tgz", @@ -6027,6 +6733,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6079,14 +6791,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6110,6 +6814,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -6157,9 +6870,9 @@ } }, "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { "client-only": "0.0.1" @@ -6168,7 +6881,7 @@ "node": ">= 12.0.0" }, "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "peerDependenciesMeta": { "@babel/core": { @@ -6328,9 +7041,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6474,9 +7187,9 @@ } }, "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -6652,17 +7365,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6678,7 +7391,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6730,9 +7443,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -6868,6 +7581,21 @@ "node": ">=20" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6885,6 +7613,31 @@ "node": ">=8" } }, + "node_modules/xdg-app-paths": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz", + "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1", + "xdg-portable": "^7.2.0" + }, + "engines": { + "node": ">= 6.0" + } + }, + "node_modules/xdg-portable": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz", + "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==", + "license": "MIT", + "dependencies": { + "os-paths": "^4.0.1" + }, + "engines": { + "node": ">= 6.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -6926,6 +7679,15 @@ "node": ">=0.4" } }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 67f9b25..12de14f 100644 --- a/package.json +++ b/package.json @@ -13,19 +13,19 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1069.0", "@tailwindcss/typography": "^0.5.19", - "@vercel/blob": "^2.4.0", + "@vercel/blob": "^2.6.1", "fuse.js": "^7.3.0", "geist": "^1.7.1", "idb": "^8.0.3", "jspdf": "^4.2.1", "jszip": "^3.10.1", "marked": "^18.0.5", - "next": "^14.2.35", + "next": "^16.2.10", "next-auth": "^5.0.0-beta.31", "pdfjs-dist": "^6.0.227", "pg": "^8.21.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "shiki": "^4.1.0" @@ -35,14 +35,26 @@ "@testing-library/react": "^16.3.2", "@types/node": "^22.9.0", "@types/pg": "^8.20.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.20", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", "playwright-core": "^1.61.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.14", "typescript": "^5.6.3", "vitest": "^4.1.7" + }, + "overrides": { + "@vercel/blob": { + "undici": "6.27.0" + }, + "jsdom": { + "undici": "7.28.0" + }, + "next": { + "postcss": "8.5.16" + } } } diff --git a/middleware.ts b/proxy.ts similarity index 87% rename from middleware.ts rename to proxy.ts index ded2d44..171fce1 100644 --- a/middleware.ts +++ b/proxy.ts @@ -1,9 +1,10 @@ import NextAuth from "next-auth"; import { NextResponse } from "next/server"; import { authConfig } from "@/auth.config"; +import { createTokenBucketRateLimiter } from "@/lib/rateLimit"; +import { enforceIpRateLimit } from "@/lib/rateLimitGuard"; -// Use an edge-safe slice of the config here — the full auth.ts pulls in pg, -// which can't run in the Edge runtime. +// Use a DB-free slice of the config here so request interception stays cheap. const { auth } = NextAuth(authConfig); // Per-request Content-Security-Policy. script-src is locked to a fresh nonce @@ -19,6 +20,10 @@ const { auth } = NextAuth(authConfig); // nothing interactive works locally. Both are gated to development; production // stays strict. const isDev = process.env.NODE_ENV !== "production"; +const publicShareRateLimit = createTokenBucketRateLimiter({ + limit: 120, + windowMs: 60_000, +}); function buildCsp(nonce: string): string { return [ @@ -60,6 +65,16 @@ function withCsp(requestHeaders: Headers): NextResponse { export default auth((req) => { const { pathname } = req.nextUrl; + if (pathname.startsWith("/p/")) { + const limited = enforceIpRateLimit( + publicShareRateLimit, + req.headers, + "public-share", + "Too many shared-note requests. Try again shortly.", + ); + if (limited) return limited; + } + // /p/. → /p//raw.txt — keeps the public-facing URL short // and lets the browser download against the extension Keep detected (.py, // .md, …), not just .txt. Share tokens never contain a dot, so a dotted diff --git a/public/sw.js b/public/sw.js index a87c10c..579cce0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,9 +1,12 @@ -const CACHE_NAME = "keep-v2"; -const SHELL_URLS = ["/"]; +const CACHE_NAME = "keep-static-v4"; +const OFFLINE_URL = "/offline"; self.addEventListener("install", (event) => { + // Precache the neutral offline page so a navigation with no network falls back + // to it instead of the browser's error screen. It carries no account data, + // unlike the personalized app shell, which is never cached. event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)) + caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL)).catch(() => {}), ); self.skipWaiting(); }); @@ -11,32 +14,53 @@ self.addEventListener("install", (event) => { self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) ) ); self.clients.claim(); }); self.addEventListener("fetch", (event) => { - const url = new URL(event.request.url); + const request = event.request; + const url = new URL(request.url); + if (request.method !== "GET" || url.origin !== self.location.origin) return; - if (url.pathname.startsWith("/api/")) return; - // Native auth bridge (/native/google, /native/bridge) ends in a redirect to - // the app's keep:// scheme. Fetching that custom scheme throws, and the - // catch below would answer "Offline" — breaking native Google sign-in. Let - // these navigations go straight to the network. - if (url.pathname.startsWith("/native/")) return; - if (event.request.method !== "GET") return; + // Navigations always hit the network for the real (personalized) page — that + // shell is never cached — but fall back to the neutral /offline page rather + // than the browser's error screen when there's no connection. + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => + caches.match(OFFLINE_URL).then( + (cached) => cached ?? new Response("Offline", { status: 503 }), + ), + ), + ); + return; + } + + // Never persist HTML or user-addressable routes. In particular, shared notes, + // auth pages, and personalized server-rendered headers must disappear when + // revoked or signed out. IndexedDB owns note data; this cache owns static files. + const staticAsset = + url.pathname.startsWith("/_next/static/") || + url.pathname === "/pdf.worker.min.mjs" || + ["script", "style", "font", "image", "worker"].includes(request.destination); + if (!staticAsset) return; event.respondWith( - fetch(event.request) - .then((response) => { - if (response.ok && url.origin === self.location.origin) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - } - return response; - }) - .catch(() => caches.match(event.request).then((r) => r || new Response("Offline", { status: 503 }))) + caches.match(request).then((cached) => { + const fresh = fetch(request) + .then((response) => { + const cacheControl = response.headers.get("cache-control") ?? ""; + if (response.ok && !/no-store|private/i.test(cacheControl)) { + const clone = response.clone(); + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, clone))); + } + return response; + }) + .catch(() => cached ?? new Response("Offline", { status: 503 })); + return cached ?? fresh; + }) ); }); diff --git a/screenshots/offline-2026-07-09-460e3a9.png b/screenshots/offline-2026-07-09-460e3a9.png new file mode 100644 index 0000000..4c0c1d7 Binary files /dev/null and b/screenshots/offline-2026-07-09-460e3a9.png differ diff --git a/scripts/ios-screenshot.sh b/scripts/ios-screenshot.sh index ea1342a..a9e9183 100755 --- a/scripts/ios-screenshot.sh +++ b/scripts/ios-screenshot.sh @@ -5,9 +5,8 @@ # ios/screenshots/ and refreshes ios/docs/screenshot.png (the image shown in # ios/README.md), so design changes are tracked over time. # -# The iOS client has no native auth yet (see ios/README.md), so against an -# unreachable/unauthed backend it renders its empty state — that's expected for -# now; the point is to track the UI shell as it evolves. +# Against an unreachable or unauthenticated backend the app renders its sign-in +# state; the screenshot still tracks the native UI shell as it evolves. # # Requirements: Xcode + xcodegen + jq (all present on GitHub macOS runners). # Env overrides: diff --git a/tsconfig.json b/tsconfig.json index afedc74..803331c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,11 +15,27 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./*"] } + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }