SPEC-0005: REST API Layer
Overview
This specification defines the JSON REST API for joe-links, served at /api/v1. The API provides programmatic access to all link management, tag browsing, and admin operations for CLI tools, browser extensions, and third-party integrations. It is additive alongside the existing HTMX web UI.
See 📝 ADR-0008 (REST API Layer), 📝 ADR-0009 (API Token Authentication), 📝 ADR-0001 (Technology Stack).
Requirements
Requirement: API Router Mounting
The application MUST mount a chi sub-router at the path prefix /api/v1. All routes under this prefix MUST return Content-Type: application/json. The /api/v1 sub-router MUST be mounted before the catch-all slug resolver. The sub-router MUST apply a BearerTokenMiddleware that authenticates every request via Authorization: Bearer <token>, with the sole exception of the public GET /api/v1/config endpoint (see REQ "Server Config Discovery").
Scenario: API Route Takes Precedence Over Slug Resolver
- WHEN a request arrives at
/api/v1/links - THEN the API router MUST handle the request; the slug resolver MUST NOT be invoked
Scenario: API Responses Are JSON
- WHEN any
/api/v1/*endpoint is called - THEN the response MUST include
Content-Type: application/json
Scenario: Unauthenticated Request Rejected
- WHEN a request to any
/api/v1/*endpoint lacks a validAuthorization: Bearer <token>header - THEN the server MUST return
401 Unauthorizedwith{"error": "unauthorized", "code": "UNAUTHORIZED"}
Requirement: Standard Error Response Format
All API error responses MUST use a consistent JSON structure. The response body MUST contain an "error" field with a human-readable message and a "code" field with a machine-readable constant. Machine-readable code values MUST use UPPER_SNAKE casing (e.g. SLUG_CONFLICT, UNAUTHORIZED, TAG_WRITE_FAILED). Note: the MCP surface (SPEC-0018) deliberately keeps its lowercase error codes — that divergence is documented there.
{
"error": "slug already taken",
"code": "SLUG_CONFLICT"
}
Scenario: Validation Error Shape
- WHEN any API endpoint returns a 4xx or 5xx status
- THEN the body MUST be a JSON object with at minimum an
"error"string field
Requirement: Links Collection (GET /api/v1/links, POST /api/v1/links)
GET /api/v1/links MUST return the list of links the authenticated user owns or co-owns. For users with role admin, ALL links in the system MUST be returned. The response MUST be a JSON object with a "links" array and pagination fields.
POST /api/v1/links MUST create a new link. The request body MUST include slug and url. title, description, and tags are optional. The slug MUST satisfy the format [a-z0-9][a-z0-9\-]*[a-z0-9] and MUST NOT be a reserved slug (exact match against store.ReservedSlugs(), SPEC-0002). Creation MUST be atomic: the link and its tags are persisted in one transaction — a tag-write failure MUST roll back the link and return an error, never a link with silently dropped tags.
Scenario: List Returns Owned Links
- WHEN an authenticated non-admin user calls
GET /api/v1/links - THEN the response MUST include only links where the user appears in
link_owners
Scenario: Admin Sees All Links
- WHEN an authenticated admin user calls
GET /api/v1/links - THEN the response MUST include all links in the system
Scenario: Create Link Success
- WHEN
POST /api/v1/linksis called with a valid slug and URL - THEN the server MUST return
201 Createdwith the created link as JSON
Scenario: Create Link — Slug Conflict
- WHEN
POST /api/v1/linksis called with a slug that already exists - THEN the server MUST return
409 Conflictwith{"error": "slug already taken", "code": "SLUG_CONFLICT"}
Scenario: Create Link — Invalid Slug
- WHEN
POST /api/v1/linksis called with a slug that fails format validation or is a reserved slug - THEN the server MUST return
400 Bad Requestwith a descriptive error
Scenario: Pagination
- WHEN
GET /api/v1/links?limit=10is called and more than 10 links exist - THEN the response MUST include
"next_cursor"for the next page and"links"containing at most 10 items
Requirement: Link Resource (GET, PUT, DELETE /api/v1/links/{id})
GET /api/v1/links/{id} MUST return the full link resource for owners, co-owners, admins, and share recipients (users with a link_shares record for the link, SPEC-0010).
PUT /api/v1/links/{id} MUST update the link's url, title, description, and tags. The slug field MUST be ignored in the request body (slugs are immutable after creation). Only owners or admins MAY update a link. A failure while persisting the updated tags MUST be reported with error code TAG_WRITE_FAILED — tags MUST NOT be silently dropped.
DELETE /api/v1/links/{id} MUST delete the link. Only owners or admins MAY delete a link.
Scenario: Get Link — Owner
- WHEN an owner calls
GET /api/v1/links/{id} - THEN the server MUST return
200 OKwith the full link JSON
Scenario: Get Link — Forbidden
- WHEN a caller who is not an owner, co-owner, share recipient, or admin calls
GET /api/v1/links/{id} - THEN the server MUST return
403 Forbidden
Scenario: Update Link — Slug Is Immutable
- WHEN
PUT /api/v1/links/{id}is called with aslugfield in the body - THEN the server MUST ignore the
slugfield and MUST NOT update it
Scenario: Delete Link
- WHEN an owner calls
DELETE /api/v1/links/{id} - THEN the server MUST return
204 No Contentand the link MUST no longer be resolvable
Requirement: Co-Owner Management (/api/v1/links/{id}/owners)
GET /api/v1/links/{id}/owners MUST list all link_owners rows for the link.
POST /api/v1/links/{id}/owners MUST add a user as co-owner. The request body MUST include email. Only owners or admins MAY add co-owners.
DELETE /api/v1/links/{id}/owners/{uid} MUST remove the specified co-owner. The primary owner (is_primary = TRUE) MUST NOT be removable.
Scenario: Remove Primary Owner Blocked
- WHEN
DELETE /api/v1/links/{id}/owners/{uid}targets the primary owner - THEN the server MUST return
400 Bad Requestwith{"error": "cannot remove primary owner", "code": "PRIMARY_OWNER_PROTECTED"}
Requirement: Tags (GET /api/v1/tags, GET /api/v1/tags/{slug}/links)
GET /api/v1/tags MUST return only tags with at least one link visible to the caller under SPEC-0010 (public, owned, co-owned, or shared via link_shares), with each tag's link_count computed over visible links only. Admins MUST see all tags with full counts.
GET /api/v1/tags/{slug}/links MUST return the tagged links visible to the caller under the same model (all links for admins). It MUST return 404 when the tag does not exist or, for non-admin callers, when the tag has no links visible to them — an invisible tag MUST be indistinguishable from a nonexistent one.
Scenario: Tags Without Links Hidden
- WHEN
GET /api/v1/tagsis called and a tag has no associated links - THEN that tag MUST NOT appear in the response
Scenario: Invisible Tag Indistinguishable from Nonexistent
- WHEN a non-admin calls
GET /api/v1/tags/{slug}/linksfor a tag whose only links are other users' private or secure links not shared with the caller - THEN the server MUST return
404, identical to the response for a tag that does not exist
Requirement: Server Config Discovery (GET /api/v1/config)
GET /api/v1/config MUST be a public, unauthenticated endpoint returning non-sensitive server configuration for clients such as the browser extension (SPEC-0008). The response MUST be a JSON object containing short_keyword — the configured short-link prefix (JOE_SHORT_KEYWORD, defaulting to the first DNS label of the server hostname). It is the only /api/v1 endpoint exempt from BearerTokenMiddleware.
Scenario: Config Returned Without Authentication
- WHEN
GET /api/v1/configis called with noAuthorizationheader - THEN the server MUST return
200 OKwith{"short_keyword": "..."}
Requirement: User Profile (GET /api/v1/users/me)
GET /api/v1/users/me MUST return the authenticated user's profile: id, email, display_name, role, and created_at.
Scenario: Me Returns Caller Identity
- WHEN an authenticated user calls
GET /api/v1/users/me - THEN the server MUST return
200 OKwith the user's own profile
Requirement: Admin Endpoints (/api/v1/admin/*)
All /api/v1/admin/* routes MUST require role = admin. A separate chi middleware group MUST enforce this.
GET /api/v1/admin/users MUST return all users.
PUT /api/v1/admin/users/{id}/role MUST update the specified user's role. The request body MUST include role (valid values: user, admin).
GET /api/v1/admin/links MUST return all links in the system.
Scenario: Non-Admin Blocked
- WHEN a user with role
usercalls any/api/v1/admin/*endpoint - THEN the server MUST return
403 Forbidden
Scenario: Role Update
- WHEN an admin calls
PUT /api/v1/admin/users/{id}/rolewith{"role": "admin"} - THEN the user's role MUST be updated and the response MUST return the updated user
Requirement: Pagination
All list endpoints (/api/v1/links, /api/v1/tags, /api/v1/admin/users, /api/v1/admin/links) MUST support cursor-based pagination. The ?limit=N parameter MUST be accepted (default 50, max 200). Responses MUST include a "next_cursor" field (opaque string) when more results exist, and null when on the last page.
Scenario: Default Limit Applied
- WHEN a list endpoint is called without a
?limit=parameter - THEN the response MUST return at most 50 items
Scenario: Limit Capped
- WHEN a list endpoint is called with
?limit=999 - THEN the server MUST return at most 200 items (silently cap)
Requirement: API Response Structures
All link resources in API responses MUST follow a consistent JSON shape:
{
"id": "uuid",
"slug": "jira",
"url": "https://company.atlassian.net/jira",
"title": "Jira",
"description": "Company Jira board",
"tags": ["engineering", "tools"],
"owners": [{"id": "uuid", "email": "user@example.com", "is_primary": true}],
"created_at": "2026-02-21T12:00:00Z",
"updated_at": "2026-02-21T12:00:00Z"
}
Tag resources:
{
"slug": "engineering",
"link_count": 42
}