Notes Store — Design Spec
Status: Approved by user Date: 2026-08-05 Project:
notes-store(/home/piyush/code/Typescript/notes-store)
Goal
A local web app where users sign in with GitHub, create "apps" (namespaces), generate API keys per app, and store/retrieve string notes keyed by a note ID. A minimal reference embed component proves the API contract by fetching and rendering a note in a user's web app.
Architecture
A single Bun server process serving both the public API and the dashboard:
┌─────────────┐ ┌────────────────────────────────────┐
│ Embedded │ │ Server (Bun) │
│ component │ │ │
│ (user site) │─────▶│ /api/v1/notes/* ← API key auth │
└─────────────┘ │ /auth/* ← GitHub OAuth │
│ /dashboard/* ← session cookie │
│ ┌─────────────┴──────────┐ │
│ │ SQLite (bun:sqlite) │ │
│ └────────────────────────┘ │
└────────────────────────────────────┘- One process, one port. Serves both the public API and the dashboard.
- API at
/api/v1/*, authenticated byAuthorization: Bearer <api_key>. The key embeds the app ID; note operations are scoped to that app automatically. - Dashboard at
/dashboard/*, protected by a signed session cookie set after GitHub OAuth login. - Reference component: a static JS file served at
/embed.jsthat fetches a note via the public API and injects its content into the page.
Tech Stack
- Runtime: Bun (
bun:sqlitebuilt-in, zero external deps for storage) - Language: TypeScript (strict mode)
- Storage: SQLite via
bun:sqlite, single file (e.g.data/notes.db) - Auth: Better Auth (GitHub OAuth + sessions; maintained successor to Lucia/arctic)
- ORM: Drizzle (typed, SQL-first, first-party
bun:sqlitedriver, no codegen) - IDs: nanoid (URL-friendly, used for user/app/key/note IDs)
- Dashboard: server-rendered HTML, no SPA framework
- Lint/format: Biome (single tool)
- Testing:
bun test(unit + integration) - Runtime: local only for now; Docker files come later (out of scope)
Global Constraints
- Lint and formatting are enforced via Biome +
tsc --noEmit; a pre-commit hook runs lint + format + tests. - API keys are stored only as SHA-256 hashes. The raw key is shown exactly once, at creation.
- The note
contentis a string; max length 100,000 chars. - The note
key(when user-supplied) must match[A-Za-z0-9_-]{1,64}. - API error responses use the shape
{ "error": { "code", "message" } }. - No versioning of notes; updates overwrite content.
- API keys are full-access per app (no read/write scoping) for now.
- App ID never appears in API requests; it is embedded in the key.
Data Model
users
| column | type | notes |
|---|---|---|
id | text PK | nanoid |
github_id | int, unique | from GitHub OAuth |
github_username | text | for display |
created_at | text | ISO timestamp |
apps
| column | type | notes |
|---|---|---|
id | text PK | nanoid |
user_id | text FK → users | owner |
name | text | user-visible label |
created_at | text | ISO timestamp |
api_keys
| column | type | notes |
|---|---|---|
id | text PK | nanoid |
app_id | text FK → apps | which app |
key_hash | text, unique | SHA-256 of full ns_<appId>_<secret> |
key_prefix | text | first ~12 chars, for display |
created_at | text | ISO timestamp |
revoked_at | text nullable | null = active |
notes
| column | type | notes |
|---|---|---|
id | text PK | the file key (user-supplied or generated nanoid) |
app_id | text FK → apps | which app namespace |
content | text | the string |
created_at | text | ISO timestamp |
updated_at | text | ISO timestamp |
Uniqueness: (app_id, id) — the same note key can exist in different apps without colliding; within an app it is unique.
API Design
Base path: /api/v1. All requests need Authorization: Bearer <api_key>.
Key format
ns_<appId>_<secret>ns_prefix for recognizability.<appId>is a nanoid identifying the app namespace.<secret>is a nanoid (~128 bits), the actual credential.- Stored only as
SHA-256of the full string;key_prefixholds the display prefix.
Notes CRUD
Create note
POST /api/v1/notes
Body: { "key": "my-note" | null, "content": "Hello" }
→ 201 { "note": { "id", "content", "createdAt", "updatedAt" } }keyoptional; omitted → generated nanoid.- 409 if the key already exists in this app.
Get note
GET /api/v1/notes/:key
→ 200 { "note": { "id", "content", "createdAt", "updatedAt" } }
→ 404 if not found in this appUpdate note
PUT /api/v1/notes/:key
Body: { "content": "New text" }
→ 200 { "note": { ... } }Delete note
DELETE /api/v1/notes/:key
→ 204 No ContentAuth / key resolution
- Parse
<appId>from the Bearer token. - Look up app; missing → 401.
- Hash full key; check
api_keysfor that app, ensure not revoked → 401 if bad. - Note operations scoped to that app automatically.
Error format
{ "error": { "code": "NOT_FOUND", "message": "Note not found" } }Codes: UNAUTHORIZED (401), NOT_FOUND (404), CONFLICT (409), VALIDATION (400), INTERNAL (500).
Apps / keys
Dashboard-only for now (no public API for app/key management).
Dashboard
Server-rendered HTML at /dashboard/*, protected by session cookie.
Auth flow (GitHub OAuth)
GET /auth/login→ redirect to GitHub OAuth with client ID.- GitHub redirects to
/auth/callback?code=.... - Exchange code for token; fetch GitHub user ID + username.
- Upsert
usersrow (create on first login, else existing). - Set signed session cookie (
ns_session, HttpOnly, SameSite=Lax). - Redirect to
/dashboard.
Logout: POST /auth/logout clears cookie.
Pages
- Login — "Sign in with GitHub" button when no session.
- Dashboard home — list the user's apps.
- App page — notes (list, create, edit, delete) + keys (create, show once, revoke).
- No separate users page; one user per GitHub account.
Notes UI
- List notes for the app: key + content preview.
- Create: small form (key optional, content textarea).
- Edit: small edit view.
- Delete: button with confirm.
- No pagination or search for now.
Keys UI
- List active + revoked keys (prefix + created date).
- "Generate key" button → modal showing raw key once with copy button.
- Revoke button with confirm → sets
revoked_at.
Reference Component
Static script at /embed.js:
<script src="https://our-server/embed.js"
data-api-key="ns_abc123xyz_x7Kq2mP9vLt4Rw8s"
data-note-key="my-note"></script>Fetches GET /api/v1/notes/<note-key> with the API key, renders content as text into the script's host element (or a designated element). Served with no auth (it is a public static file; the API key lives in the user's page).
Error Handling
- Consistent JSON error shape for the API.
- Validation at the API boundary:
contentstring (max 100k),keyregex[A-Za-z0-9_-]{1,64}when provided → 400. - DB errors (unique constraint) → 409; unexpected → log + generic 500 (no internals leaked).
- 401 never distinguishes "bad key" from "no key".
- Dashboard: unauthenticated → redirect to login; forms validated server-side.
Testing
- Unit:
bun test— key parse/hash logic, validation, DB helpers. - API integration: server against a temp/in-memory SQLite, CRUD with a real key, statuses + bodies (happy + 401/404/409).
- Auth: mocked GitHub token exchange (stub fetch), assert session cookie + redirect.
- Dashboard smoke: logged-out GET
/dashboardredirects to login.
Linting & Formatting
- Biome for lint + format (single tool).
tsc --noEmittype gate.- Pre-commit hook runs lint + format + tests.
bun run checkscript runs all of the above.
Out of Scope (later)
- Docker files / deployment.
- Note versioning/history.
- Read/write key scoping.
- Public admin API for apps/keys.
- Frontend polish of the dashboard (a frontend-design skill pass later).
- Pagination/search on dashboard notes.
- Multi-user sessions beyond GitHub identity (one user per GitHub account).