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
25 changes: 19 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Postgres connection string used by lib/db.ts.
# For a self-hosted Postgres with a self-signed cert, keep sslmode=require —
# lib/db.ts honors it but disables cert verification (rejectUnauthorized: false).
# For a managed provider (Neon, Supabase, RDS, etc.) the same URL format works.
# TLS certificates are verified against the system trust store by default.
# Managed providers normally work with sslmode=require as written below.
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
# 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).
# connection verifies the server against that CA. When unset, system CAs apply.
DATABASE_CA_CERT=

# Local development only. Set this alongside sslmode=no-verify when a loopback
# Postgres uses a self-signed certificate. Never enable it across a network.
DATABASE_TLS_INSECURE=false

# Auth.js (NextAuth v5) session signing key. Generate with: openssl rand -base64 32
AUTH_SECRET=

Expand All @@ -24,6 +25,8 @@ AUTH_GOOGLE_SECRET=
# Optional. Anthropic key for AI note titles/summaries (Claude Haiku). If unset,
# Keep falls back to local zero-token title inference. ANTHROPIC_API_KEY also works.
ANTHROPIC_KEY=
# Explicit privacy opt-in: when true, note text is sent to Anthropic for metadata.
AI_METADATA_ENABLED=false
# Override the model (default: claude-haiku-4-5-20251001).
ANTHROPIC_MODEL=

Expand All @@ -50,3 +53,13 @@ LOG_LEVEL=
# Optional. Email of the account allowed to view the /analytics dashboard.
# Unset → the dashboard 404s for everyone (analytics are still collected).
ANALYTICS_ADMIN_EMAIL=

# Optional private image storage. Choose S3-compatible storage or Vercel Blob.
# Keep the S3 bucket private; the application streams authorized objects.
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=false
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
BLOB_READ_WRITE_TOKEN=
27 changes: 17 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@ on:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- run: npm ci
# Reuse Next's incremental build cache across runs so the gate build is warm.
- name: Cache Next.js build
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
- run: npx tsc --noEmit
- run: npm test
- run: npm audit --audit-level=high
- run: npm run build

# Auto-deploy to the production droplet on every merge to main, only after
Expand All @@ -46,19 +50,22 @@ jobs:
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/keep' }}
DEPLOY_SERVICE: ${{ vars.DEPLOY_SERVICE || 'keep' }}
run: |
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
echo "::notice::DEPLOY_HOST/DEPLOY_SSH_KEY not configured — skipping deploy."
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_SSH_KEY" ] || [ -z "$DEPLOY_KNOWN_HOSTS" ]; then
echo "::notice::DEPLOY_HOST/DEPLOY_SSH_KEY/DEPLOY_KNOWN_HOSTS not configured — skipping deploy."
exit 0
fi
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=~/.ssh/known_hosts \
"$DEPLOY_HOST" \
"set -e
cd '$DEPLOY_PATH'
Expand All @@ -78,7 +85,7 @@ jobs:
run: |
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$DEPLOY_URL" || echo 000)
echo "GET $DEPLOY_URL -> $code"
[ "$code" = "200" ] || echo "::warning::smoke check returned $code"
[ "$code" = "200" ]

# Refresh the desktop screenshot (docs/screenshot.png, shown in the README) on
# every merge to main, after the deploy lands — the CI analog of the manual
Expand All @@ -98,8 +105,8 @@ jobs:
group: desktop-screenshot
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
Expand Down
41 changes: 31 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Postgres, with a guest mode that keeps notes in the browser until sign-in.
## Features

- Debounced autosave for new and existing notes
- LLM-generated titles and summaries through Anthropic, with a local fallback
- Local title/summary inference, with explicit opt-in Anthropic enhancement
- 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
Expand All @@ -30,26 +30,31 @@ Postgres, with a guest mode that keeps notes in the browser until sign-in.
- 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
- Private, authenticated 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.
service worker, so a full page reload still requires a network connection. An
explicit sign-out removes that account's IndexedDB cache and queued mutations.

## Getting started

```bash
npm install
cp .env.example .env.local
# Set AUTH_SECRET and DATABASE_URL.
# Set AUTH_SECRET, AUTH_URL, and DATABASE_URL.
# Add Google, Resend, Anthropic, and object-storage settings as needed.
npm run dev
```

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.
The idempotent bootstrap in `lib/db.ts` creates the notes, users, uploads,
native-auth, audit, and analytics tables on first use. A transient bootstrap
failure is retryable on the next request.

AI metadata is disabled by default. Setting `AI_METADATA_ENABLED=true` and an
Anthropic key sends up to the first 8 KiB of authenticated note text to
Anthropic for a title and summary. Guest text always stays in the browser.

Useful checks:

Expand All @@ -65,6 +70,7 @@ npm audit
```text
app/
api/notes/ Authenticated CRUD, import/export, titles, sharing
api/uploads/ Owner/share-authorized private image delivery
api/auth/ Auth.js plus registration and email verification
note/[noteId]/ Stable note deep links
p/[token]/ Public shared-note pages
Expand All @@ -86,13 +92,28 @@ proxy.ts Auth gate, CSP, rewrites, and public rate limits
## Deployment

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.
test, typecheck, audit, and production-build gates before deploying the latest
`main` commit. The deploy environment requires a pinned `DEPLOY_KNOWN_HOSTS`
entry in addition to the host and SSH key. `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.

## Security and privacy

Keep's server can read note content: notes are stored as plaintext Postgres
columns so server search, export, and optional AI metadata work. Browser guest
notes are plaintext localStorage; signed-in offline copies are plaintext
IndexedDB scoped by account. The native clients put titles, summaries, and tags
in Spotlight, while full note bodies stay out of the system index.

Random public share links use 128-bit tokens. Vanity tokens must be at least 16
characters and should still be treated as public URLs. Uploaded images remain
private objects and are served only to their owner or through a note that
currently has a valid share token. See [SECURITY.md](./SECURITY.md) for the
deployment checklist and reporting process.

## License

MIT — see [LICENSE](./LICENSE).
44 changes: 44 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Security

## Reporting a vulnerability

Please use a private [GitHub security advisory](https://github.com/fjbarrett/keep/security/advisories/new).
Include the affected route or client, reproduction steps, and likely impact. Do
not put credentials, private notes, or an unpatched exploit in a public issue.

## Deployment checklist

- Set a strong `AUTH_SECRET` and the exact HTTPS `AUTH_URL`.
- Keep Postgres on a private network. TLS verifies system CAs by default; use
`DATABASE_CA_CERT` for a private CA. `DATABASE_TLS_INSECURE=true` is only for
loopback development with `sslmode=no-verify`.
- Keep attachment storage private. S3 deployments should enable Block Public
Access and grant the application only object read/write/delete permissions
under the `keep/` prefix.
- Set `DEPLOY_KNOWN_HOSTS` to a separately verified SSH host-key line. Deployment
fails closed when the host key changes.
- Leave `AI_METADATA_ENABLED=false` unless sending authenticated note text to
Anthropic is acceptable and disclosed to users. Provider and account budgets
should also be configured outside the application.
- Back up Postgres and object storage with encryption at rest and tested restore
procedures. Restrict production logs and database access to operators who need
them.

## Data boundaries

Keep is not end-to-end encrypted. The server can read note content. Signed-in
web clients keep account-scoped offline copies in IndexedDB until explicit
sign-out or site-data removal; guest notes remain in localStorage. Native
Spotlight integration indexes titles, summaries, and tags, but not note bodies.

Public shares are bearer links. Random links carry 128 bits of entropy; vanity
links are public names with a 16-character minimum. Revocation stops page,
download, and attachment access immediately.

## Upgrade note for public attachments

Releases before the private-upload change wrote attachments with public-read
storage permissions and embedded their provider URLs in notes. New uploads are
private and use `/api/uploads/<id>`. Operators upgrading an existing deployment
should inventory old `keep/` objects, remove public ACLs after deciding how to
handle legacy note links, and delete unreferenced public objects.
26 changes: 26 additions & 0 deletions __tests__/appUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it } from "vitest";
import { appOrigin, isSameOriginMutation } from "@/lib/appUrl";

afterEach(() => {
delete process.env.AUTH_URL;
});

describe("canonical application origin", () => {
it("uses configured AUTH_URL instead of a request-controlled host", () => {
process.env.AUTH_URL = "https://keeptxt.com/some/path";
expect(appOrigin(new Request("https://attacker.invalid/register")))
.toBe("https://keeptxt.com");
});

it("rejects same-site requests from another origin", () => {
process.env.AUTH_URL = "https://keeptxt.com";
const request = new Request("https://keeptxt.com/api/auth/register", {
method: "POST",
headers: {
origin: "https://untrusted.keeptxt.com",
"sec-fetch-site": "same-site",
},
});
expect(isSameOriginMutation(request)).toBe(false);
});
});
28 changes: 27 additions & 1 deletion __tests__/dbCaCert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,40 @@ import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { caCert } from "@/lib/db";
import { caCert, databaseSslOptions } from "@/lib/db";

const PEM = "-----BEGIN CERTIFICATE-----\nMIIabc123\n-----END CERTIFICATE-----\n";

afterEach(() => {
delete process.env.DATABASE_CA_CERT;
});

describe("databaseSslOptions", () => {
it("verifies certificates by default for TLS connections", () => {
expect(databaseSslOptions("require")).toEqual({ rejectUnauthorized: true });
expect(databaseSslOptions("verify-full")).toEqual({ rejectUnauthorized: true });
});

it("uses a configured private CA while retaining verification", () => {
expect(databaseSslOptions("require", PEM)).toEqual({
ca: PEM,
rejectUnauthorized: true,
});
});

it("requires an explicit flag before disabling verification", () => {
expect(() => databaseSslOptions("no-verify")).toThrow(/DATABASE_TLS_INSECURE/);
expect(databaseSslOptions("no-verify", undefined, true)).toEqual({
rejectUnauthorized: false,
});
});

it("does not enable TLS when sslmode is absent or disabled", () => {
expect(databaseSslOptions(null)).toBeUndefined();
expect(databaseSslOptions("disable")).toBeUndefined();
});
});

describe("caCert", () => {
it("returns undefined when DATABASE_CA_CERT is unset", () => {
expect(caCert()).toBeUndefined();
Expand Down
23 changes: 23 additions & 0 deletions __tests__/imageUpload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { hasValidImageSignature, imageExtension } from "@/lib/imageUpload";

describe("image upload validation", () => {
it("recognizes allowed raster signatures", () => {
expect(hasValidImageSignature(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
"image/png",
)).toBe(true);
expect(hasValidImageSignature(
new TextEncoder().encode("GIF89a"),
"image/gif",
)).toBe(true);
expect(imageExtension("image/jpeg")).toBe("jpg");
});

it("rejects HTML mislabeled as an image", () => {
expect(hasValidImageSignature(
new TextEncoder().encode("<script>alert(1)</script>"),
"image/png",
)).toBe(false);
});
});
5 changes: 5 additions & 0 deletions __tests__/nativeExchangeRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ describe("/api/native/exchange", () => {
const res = await exchange("not json");
expect(res.status).toBe(400);
});

it("rejects oversized bodies before database work", async () => {
const res = await exchange(JSON.stringify({ code: "x".repeat(3000) }));
expect(res.status).toBe(413);
});
});
3 changes: 2 additions & 1 deletion __tests__/noteCreateIdempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ beforeEach(() => {
describe("POST /api/notes idempotency", () => {
it("returns an existing owned note when an offline create is replayed", async () => {
mocks.query
.mockResolvedValueOnce({ rows: [{ count: "0" }] })
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [row] });

Expand All @@ -53,6 +54,6 @@ describe("POST /api/notes idempotency", () => {

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ note: row });
expect(mocks.query).toHaveBeenCalledTimes(2);
expect(mocks.query).toHaveBeenCalledTimes(3);
});
});
Loading
Loading