10 KiB
Secure Notes Vault API
System overview
Secure Notes Vault API is a Spring Boot REST service for creating, managing, and sharing private notes. Users register and log in with username/password credentials, then authenticate subsequent requests with a JWT bearer token.
The application is organized around a standard controller-service-repository flow:
- Controllers expose the authentication and notes REST endpoints.
- Spring Security and a JWT filter authenticate protected requests.
- Services enforce note ownership and read-only shared-note access rules.
- JPA repositories persist users, notes, and note shares.
- Flyway migrations manage the database schema for both local H2 and PostgreSQL-backed runs.
Running the program
This project requires Java 21 and Maven. The API starts on http://localhost:8080.
For the fastest local setup, run the app with the in-memory H2 database:
make dev
To run the test suite:
make test
To run the API and PostgreSQL with Docker Compose:
make docker-up
Stop the Docker Compose stack with:
make docker-down
You can also run the Spring Boot app directly:
JWT_SECRET=dev-secret-change-me-do-not-use-in-production mvn spring-boot:run
Design decisions
- Java 21 was selected because the challenge is for a Java-oriented backend role and because modern Java records work well for request/response DTOs.
- Spring Boot provides a compact way to build a production-style REST API with validation, dependency injection, security, persistence, and test support without adding much custom framework code.
- Maven is used for dependency management and repeatable local builds because it is widely understood in Java backend environments.
- Spring Security handles authentication boundaries, while a custom JWT filter keeps the API stateless after login. This makes the service easier to run behind a load balancer because requests do not depend on server-side sessions.
- Passwords are stored as BCrypt hashes, never as plaintext. The JWT signing secret is supplied through configuration and should come from a secret manager or environment variable outside local development.
- H2 is used for fast local development and automated tests. PostgreSQL is supported through Docker Compose and a Spring profile because it is a better fit for a production relational datastore.
- JPA/Hibernate keeps the persistence layer concise for this size of application. The schema is still managed explicitly with Flyway so database changes are versioned and repeatable.
- The data model follows the required
User,Note, andNoteShareentities, with an addedtitlefield on notes for a more realistic note-taking API. - Share records grant read-only access to a specific user. Owners can create, update, delete, and share their notes; shared recipients can only read them.
- UUID primary keys are used so identifiers are opaque and do not expose record counts or ordering.
- The service returns structured error responses for application-level failures so clients can handle validation, authentication, authorization, and conflict cases predictably.
- This implementation favors clarity over extra infrastructure. Features such as refresh tokens, token revocation, audit logging, pagination, rate limiting, and OpenAPI generation would be good production follow-ups.
Test App
A React app located at https://git.seanstarkey.dev/starkey/notes was used to test the API. This app exercises the API and displays REST requests/responses for debugging. It runs in a docker container.
API Endpoints
Authentication
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/register |
Register a new user |
POST |
/auth/login |
Authenticate and receive a JWT |
Notes
| Method | Endpoint | Description |
|---|---|---|
POST |
/notes |
Create a new note |
GET |
/notes |
List owned notes and notes shared with you |
GET |
/notes/{id} |
Get a note by ID (owner or shared recipient) |
PUT |
/notes/{id} |
Update a note (owner only) |
DELETE |
/notes/{id} |
Delete a note (owner only) |
POST |
/notes/{id}/share |
Share a note with another user (read-only) |
API Usage Examples
The following examples assume the API is running at http://localhost:8080.
Register two users:
curl -i -X POST http://localhost:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"password123"}'
curl -i -X POST http://localhost:8080/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"bob","password":"password123"}'
Log in and save each JWT:
ALICE_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"password123"}' | jq -r '.token')
BOB_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"bob","password":"password123"}' | jq -r '.token')
Create a note as Alice:
NOTE_ID=$(curl -s -X POST http://localhost:8080/notes \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Launch checklist","content":"Rotate credentials before launch."}' | jq -r '.id')
List Alice's visible notes:
curl -i http://localhost:8080/notes \
-H "Authorization: Bearer $ALICE_TOKEN"
Share Alice's note with Bob:
curl -i -X POST http://localhost:8080/notes/$NOTE_ID/share \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"bob"}'
Bob can read the shared note:
curl -i http://localhost:8080/notes/$NOTE_ID \
-H "Authorization: Bearer $BOB_TOKEN"
Bob cannot update the shared note because shared access is read-only:
curl -i -X PUT http://localhost:8080/notes/$NOTE_ID \
-H "Authorization: Bearer $BOB_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Edited by Bob","content":"This update should be rejected."}'
HTTP return codes
Application-defined HTTP status codes are generated in AuthController for successful registration and in GlobalExceptionHandler for API error responses. Spring Security and Spring MVC may still produce framework-level responses for requests that do not reach a controller, such as unauthenticated protected requests or unsupported methods.
| HTTP code | Status | Generated in | Trigger | Response body |
|---|---|---|---|---|
201 |
Created | AuthController.register |
POST /auth/register creates a new user. |
UserResponse with id, username, and createdAt. |
400 |
Bad Request | GlobalExceptionHandler.handleMalformedJson |
Request body cannot be parsed or converted from JSON. | ErrorResponse with Malformed request body. |
400 |
Bad Request | GlobalExceptionHandler.handleTypeMismatch |
Path variable or query parameter cannot be converted to the expected type. | ErrorResponse with Invalid request parameter. |
401 |
Unauthorized | GlobalExceptionHandler.handleAuthentication; Spring Security filter chain |
Authentication fails or an unauthenticated request targets a protected route. | ErrorResponse with Invalid username or password when handled by GlobalExceptionHandler; framework-generated body when rejected before controller handling. |
403 |
Forbidden | GlobalExceptionHandler.handleForbidden |
Authenticated caller is not allowed to perform the requested operation. | ErrorResponse with the exception message. |
404 |
Not Found | GlobalExceptionHandler.handleNotFound |
Requested domain resource does not exist or should be hidden from the caller. | ErrorResponse with the exception message. |
404 |
Not Found | GlobalExceptionHandler.handleNoResourceFound |
No controller route or static resource matches the request. | ErrorResponse with Not found. |
409 |
Conflict | GlobalExceptionHandler.handleConflict; AuthController.register throws ConflictException |
Duplicate username or other persistence/domain uniqueness conflict. | ErrorResponse with the exception message. |
422 |
Unprocessable Entity | GlobalExceptionHandler.handleValidation |
Bean Validation fails for a request body annotated with @Valid. |
ValidationErrorResponse with error and per-field fields. |
Database
- The schema is the basic requirements for a User, Note and NoteShare.
- H2 database is used for development.
- Flyway will allow us to migrate to Postgres for "production".
| Table | Column | Type | Required | Key / Constraint | Description |
|---|---|---|---|---|---|
users |
id |
UUID |
Yes | Primary key | Unique user identifier. |
users |
username |
VARCHAR(100) |
Yes | Unique: uq_users_username |
User login handle. |
users |
password_hash |
VARCHAR(255) |
Yes | BCrypt password hash. | |
users |
created_at |
TIMESTAMP WITH TIME ZONE |
Yes | Account creation timestamp. | |
notes |
id |
UUID |
Yes | Primary key | Unique note identifier. |
notes |
owner_id |
UUID |
Yes | FK to users(id); indexed by idx_notes_owner_id |
User who owns the note. |
notes |
title |
VARCHAR(255) |
Yes | Note title. | |
notes |
content |
TEXT |
Yes | Note body content. | |
notes |
created_at |
TIMESTAMP WITH TIME ZONE |
Yes | Note creation timestamp. | |
notes |
updated_at |
TIMESTAMP WITH TIME ZONE |
Yes | Last note update timestamp. | |
note_shares |
id |
UUID |
Yes | Primary key | Unique share identifier. |
note_shares |
note_id |
UUID |
Yes | FK to notes(id) with ON DELETE CASCADE; indexed by idx_note_shares_note_id |
Note being shared. |
note_shares |
shared_with_user_id |
UUID |
Yes | FK to users(id); indexed by idx_note_shares_shared_with_user_id |
User receiving read-only access. |
note_shares |
created_at |
TIMESTAMP WITH TIME ZONE |
Yes | Share creation timestamp. | |
note_shares |
note_id, shared_with_user_id |
UUID, UUID |
Yes | Unique: uq_note_shares_note_user |
Prevents duplicate shares for the same note and user. |
References
A list of projects looked at for reference.