- Overview
- Features
- Architecture
- Tech Stack
- Project Structure
- Installation
- Development
- API
- Deployment
- Testing
- Security
- Troubleshooting
- License
Bookmarket is a full-stack bookmark manager for saving, organizing, searching, and sharing web links without relying on browser bookmark folders.
The current implementation uses a Next.js web app, a Kotlin Spring Boot API, a Go metadata worker, Postgres as the source of truth, Kafka for asynchronous metadata jobs, Redis for operational state, and Elasticsearch for derived bookmark search.
- Email, Google, and GitHub authentication with rotating refresh sessions.
- Bookmark creation that returns immediately while metadata is fetched in the background.
- Metadata status indicators so users can see when a bookmark is still being enriched.
- Title, description, canonical URL, favicon, oEmbed, and optional browser rendered metadata fallbacks.
- Category organization and public profile sharing at
/s/[username]. - User-scoped bookmark search with Postgres fallback and optional Elasticsearch indexing.
- API tokens for external clients such as Raycast.
- Raspberry Pi k3s deployment manifests with Postgres, Kafka, Redis, and Elasticsearch.
apps/web Next.js App Router UI
|
| HTTP / cookies / server actions
v
services/api Kotlin Spring Boot API
|
| Postgres writes + Kafka events + Redis cache
v
services/metadata-worker Go worker consuming metadata jobs
|
| metadata projection + completion events
v
Postgres / Redis / Kafka / Elasticsearch
Core rule: Postgres owns durable user data. Kafka, Redis, and Elasticsearch are derived or operational systems.
Bookmark creation is intentionally non-blocking:
- The web app submits a URL.
- The API stores the bookmark with
metadataStatus: "PENDING". - The UI renders the row immediately.
- Kafka queues
metadata.fetch.requested. - The metadata worker fetches and stores metadata.
- The UI refreshes when metadata becomes
READYorFAILED.
- Next.js 15 App Router
- React 19
- TypeScript 5.7
- Tailwind CSS
- Radix UI
- TanStack Query
- Zustand
- Sentry
- next-pwa / Workbox
- Kotlin
- Spring Boot
- Spring Security
- Flyway
- Postgres
- Redis
- Kafka
- Elasticsearch
- Testcontainers
- Go
- Kafka consumer/producer
- Postgres projection writer
- SSRF-protected URL fetching
- oEmbed provider fallback
- Optional Obscura browser-rendered fallback
- pnpm workspace
- Docker Compose for local dependencies
- Terraform for Raspberry Pi k3s resources
- GitHub Actions for CI and ARM64 image builds
bookmarket/
├── apps/
│ └── web/ # Next.js web app
├── services/
│ ├── api/ # Kotlin Spring Boot API
│ └── metadata-worker/ # Go metadata worker
├── infra/
│ ├── docker-compose/ # Local Postgres/Redis/Kafka/Elasticsearch
│ └── terraform/pi/ # Raspberry Pi k3s deployment
├── docs/
│ ├── architecture/ # Service and data-flow notes
│ ├── contracts/ # API, event, and error contracts
│ ├── domain/ # Product-domain notes
│ └── operations/ # Deployment and smoke-check docs
├── scripts/ # CI and operations helpers
├── package.json
└── pnpm-workspace.yaml
- Node.js 22
- pnpm 8.9.2
- Docker Desktop or another Docker engine
- Java 11
- Maven
- Go 1.25 or the version declared in
services/metadata-worker/go.mod - Terraform for Pi infrastructure checks
git clone https://github.com/ericjypark/bookmarket.git
cd bookmarket
pnpm install
pnpm compose:upStart the services in separate terminals:
pnpm dev:web
pnpm start:api
pnpm build:metadata-worker
pnpm start:metadata-workerDefault local URLs:
- Web:
http://localhost:3000 - API:
http://localhost:8080 - Metadata worker health:
http://localhost:8081/health
Useful commands:
pnpm lint:web
pnpm build:web
pnpm test:api
pnpm test:metadata-worker
pnpm contracts:validate
pnpm compose:verify
pnpm images:verifyLocal dependency commands:
pnpm compose:up
pnpm compose:config
pnpm compose:smoke
pnpm compose:downThe API is served under /api/v1.
Authentication:
GET /api/v1/signup-slotsPOST /api/v1/auth/signupPOST /api/v1/auth/loginPOST /api/v1/auth/oauth/statePOST /api/v1/auth/oauth/googlePOST /api/v1/auth/oauth/githubPOST /api/v1/auth/refreshPOST /api/v1/auth/logoutGET /api/v1/users/mePATCH /api/v1/users/me
Bookmarks and categories:
GET /api/v1/bookmarksPOST /api/v1/bookmarksGET /api/v1/bookmarks/{id}PATCH /api/v1/bookmarks/{id}POST /api/v1/bookmarks/{id}/metadata-refetchGET /api/v1/bookmarks/{id}/metadata-statusDELETE /api/v1/bookmarks/{id}GET /api/v1/categoriesPOST /api/v1/categoriesPATCH /api/v1/categories/{id}DELETE /api/v1/categories/{id}
Public and external clients:
GET /api/v1/public-profiles/{username}GET /api/v1/public-profiles/{username}/categoriesGET /api/v1/public-profiles/{username}/bookmarksGET /api/v1/search/bookmarks?q=GET /api/v1/api-tokensPOST /api/v1/api-tokensDELETE /api/v1/api-tokens/{id}
Full contract details are in docs/contracts/api.md and
docs/contracts/openapi.json.
Production targets a single 8GB Raspberry Pi running k3s. Terraform modules live
in infra/terraform/pi.
Build and publish ARM64 images through GitHub Actions or locally:
pnpm images:buildValidate deployment manifests:
pnpm infra:pi:verify
terraform -chdir=infra/terraform/pi init -backend=false
terraform -chdir=infra/terraform/pi validate
terraform -chdir=infra/terraform/pi plan -input=false -lock=false -no-colorProduction operations helpers:
pnpm preflight:production-context:dry-run
pnpm backup:production:dry-run
pnpm smoke:production:dry-run
pnpm public:endpoints:external:dry-runUse the non-dry-run variants only from a shell pointed at the intended Pi k3s context.
CI runs:
- Contract validation
- Architecture support validation
- Web lint and build
- API tests
- Metadata worker tests
- Docker Compose validation
- Production operations script dry-runs
- Terraform validation and plan
- Image workflow validation
Local high-signal checks:
pnpm contracts:validate
pnpm check:architecture-support
pnpm check:ci-workflow
pnpm lint:web
pnpm build:web
pnpm test:api
pnpm test:metadata-worker
pnpm compose:verify
pnpm images:verifyManual E2E verification should use Safari through Computer Use when browser state matters.
- Access tokens are short-lived.
- Refresh sessions default to 30 days and rotate on refresh.
- Auth cookies are HTTP-only.
- OAuth provider identities are verified server-side.
- Metadata fetching blocks unsupported schemes, localhost, and private network targets.
- API tokens are stored hashed and displayed only once.
- Production backup and smoke helpers refuse unsafe contexts by default.
pnpm compose:config
pnpm compose:up
pnpm compose:smokepnpm test:api uses Testcontainers-backed Postgres integration tests. Start
Docker before running it.
Check Kafka, the metadata worker process, and the API metadata event consumer:
pnpm test:metadata-worker
curl -fsS http://localhost:8081/healthSome networks do not support NAT loopback. Use the external public endpoint helper to collect read-only public health evidence:
pnpm public:endpoints:external:dry-run
pnpm public:endpoints:externalMIT
