Skip to main content
Status:🚀 ACTIVE
Date:📅 unknown
Domain:📦 REST API Layer

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 valid Authorization: Bearer <token> header
  • THEN the server MUST return 401 Unauthorized with {"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.

  • 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
  • WHEN an authenticated admin user calls GET /api/v1/links
  • THEN the response MUST include all links in the system
  • WHEN POST /api/v1/links is called with a valid slug and URL
  • THEN the server MUST return 201 Created with the created link as JSON
  • WHEN POST /api/v1/links is called with a slug that already exists
  • THEN the server MUST return 409 Conflict with {"error": "slug already taken", "code": "SLUG_CONFLICT"}
  • WHEN POST /api/v1/links is called with a slug that fails format validation or is a reserved slug
  • THEN the server MUST return 400 Bad Request with a descriptive error

Scenario: Pagination

  • WHEN GET /api/v1/links?limit=10 is 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

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.

  • WHEN an owner calls GET /api/v1/links/{id}
  • THEN the server MUST return 200 OK with the full link JSON
  • 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
  • WHEN PUT /api/v1/links/{id} is called with a slug field in the body
  • THEN the server MUST ignore the slug field and MUST NOT update it
  • WHEN an owner calls DELETE /api/v1/links/{id}
  • THEN the server MUST return 204 No Content and 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 Request with {"error": "cannot remove primary owner", "code": "PRIMARY_OWNER_PROTECTED"}

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.

  • WHEN GET /api/v1/tags is 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}/links for 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/config is called with no Authorization header
  • THEN the server MUST return 200 OK with {"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 OK with 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 user calls 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}/role with {"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
}