Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand All @@ -32,9 +32,6 @@ ANTHROPIC_MODEL=
RESEND_API_KEY=
EMAIL_FROM="Keep <verify@your-domain>"

# 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

Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 64 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `/<noteId>`, 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/<token>`) 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/<noteId>`
- 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).
17 changes: 17 additions & 0 deletions __tests__/email.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
15 changes: 15 additions & 0 deletions __tests__/identifiers.test.ts
Original file line number Diff line number Diff line change
@@ -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}$/);
});
});
58 changes: 58 additions & 0 deletions __tests__/noteCreateIdempotency.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
54 changes: 54 additions & 0 deletions __tests__/noteEditorAutosave.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Note>((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 });
Expand Down Expand Up @@ -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 }),
);
});
});
Loading
Loading