Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
**NEVER** run `bundle exec rails console`.
</rules>

## RFCs

Significant changes, architectural decisions, and new features should be proposed as RFCs in the `rfcs/` directory. RFCs use the format `rfcs/YYYY-MM-DD_short_title.md` with the following structure:

- `# Title` — short descriptive title
- `**Date:**` — proposal date (ISO format)
- `**Status:**` — `Proposed`, `Accepted`, `Implemented`, or `Rejected`
- `## Goal` — what the RFC aims to accomplish
- Remaining sections are free-form but typically include motivation, technical details, and an implementation checklist

See `rfcs/2026-05-26_rest_api_for_links_and_posts.md` for a complete example.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RFC status lifecycle

When an RFC is merged or implemented, please document that authors should update **Status:** in the file (ProposedAcceptedImplemented / Rejected). Otherwise rfcs/ will accumulate stale “Proposed” docs.

Agent discoverability

The checklist already says “Update AGENTS.md with API documentation.” When the API ships, consider a short ## API subsection with an example curl and env var name (e.g. ABBEY_API_KEY) — agents and harness-style tooling read AGENTS.md first.

## Commands

**Test**
Expand Down
212 changes: 212 additions & 0 deletions rfcs/2026-05-26_rest_api_for_links_and_posts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# REST API for Links and Posts

**Date:** 2026-05-26
**Status:** Proposed

## Goal

Add a JSON REST API to Abbey for creating, editing, and managing draft/published state of links and posts. This enables programmatic content management (e.g., from agents, scripts, or external tools) without going through the HTML form interface.

## Motivation

Currently all content creation and editing happens through the browser UI. Adding a REST API allows:

- Automated posting from agents and scripts
- Quick link saving via API calls (e.g., from a bookmarklet or mobile device)
- Programmatic draft management (create drafts, edit, publish when ready)
- Future integrations with other tools and services

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claw integration (Hermclaw, HomuncuCLAW / agent use case)

Worth a short subsection (Motivation or “Claw integration”) that maps today’s patterns to this API:

Claw need today RFC coverage
Post.create! / find_by(slug:).update! POST / PATCH /api/posts
Promote draft (update!(draft: false)) draft on PATCH
List drafts / “what’s in the queue” GET /api/posts with Bearer
Save link from URL POST /api/links
Page CRUD (/p/...) Not in v1 — call out as intentional omission or follow-up

HomuncuCLAW currently uses bin/blog → SSH → docker execrails runner. This RFC is the right replacement for that runner path; agent confirmation rails (YES, YES, DESTROY) stay in SOUL.md, not in the API.

Draft visibility on list

Please specify explicitly:

  • With Bearer: list includes drafts (mirror post_scope when authenticated? in BlogController).
  • Without Bearer: list matches Post.published only (same as public site).

That’s the main thing agents need for “show my draft queue” from a phone.


## Proposed Endpoints

### Authentication

All API endpoints require an API key passed as a Bearer token in the `Authorization` header:

```text
Authorization: Bearer <api_key>
```

API keys are generated per-user and stored in the database. The key is not the session cookie — it's a separate token specifically for API access.

### Posts

| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `GET` | `/api/posts` | List posts (respecting draft status for unauthenticated) | Optional |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET responses need full markdown for edit workflows

List/show JSON includes excerpt but not markdown_body or markdown_excerpt. Claws and scripts editing drafts need the full body on GET /api/posts/:slug (and likely authenticated list/detail).

Suggestion:

  • Authenticated GET: include markdown_body, markdown_excerpt, and tags (normalized tags array and/or post_tags string — pick one).
  • Unauthenticated GET: omit body or return truncated excerpt only, matching what the public site exposes.

Without this, agents still need rails runner for reads, which undercuts much of the motivation.

| `GET` | `/api/posts/:slug` | Get a single post by slug | Optional |
| `POST` | `/api/posts` | Create a new post | Required |
| `PATCH` | `/api/posts/:slug` | Update an existing post | Required |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slug behavior — example vs current model

The PATCH example changes slug from my-new-post to updated-title when only title is sent. In the app today, slug is assigned only on create (before_create :assign_slug). The HTML form allows manual slug edits but does not auto-regenerate from title on update.

Agents use slug as a stable identifier (PATCH /api/posts/:slug). Please pick and document one policy:

  • (a) Slug immutable after create unless slug is explicitly in the PATCH body (recommended; matches HTML).
  • (b) Slug auto-updates from title (breaking vs current UI; breaks bookmarks unless you add redirects).
  • (c) Another rule, documented explicitly.

If (a), fix the example response so slug stays my-new-post when only title changes.

Also document optional slug on POST (HTML supports it; model overwrites on create via assign_slug — clarify whether the API allows override at create time).

| `DELETE` | `/api/posts/:slug` | Delete a post | Required |

### Links

| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| `GET` | `/api/links` | List links | Optional |
| `POST` | `/api/links` | Create a new link (auto-fetches title/description) | Required |
| `PATCH` | `/api/links/:id` | Update a link's title, description, or URL | Required |
| `DELETE` | `/api/links/:id` | Delete a link | Required |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PDF URLs → papers (parity with LinksController)

HTML LinksController#create does not always create a Link. It calls PdfPaperCreator.create_from_url(url) and redirects to papers when that returns a paper.

Please document POST /api/links when the URL is a PDF, for example:

  • 201 with a paper resource (needs /api/papers or a polymorphic response),
  • 422 with a clear error (“use papers endpoint”), or
  • explicitly defer papers to v2.

MetaInspector latency and failures

Link create runs MetaInspector synchronously in before_create. API clients may see multi-second hangs or hard failures on bad hosts.

Consider documenting timeout behavior, optional skip_fetch: true with required title / description, or async create + 202 + poll (v2 is fine if called out).

GET /api/links/:id

The endpoint table has list/create/update/destroy but no show. Agents often need fetch-by-id after create. Add GET /api/links/:id or document list-only access.


### Request/Response Shapes

#### Create Post

```json
// POST /api/posts
{
"title": "My New Post",
"markdown_body": "Hello world...",
"markdown_excerpt": "A short excerpt",
"post_tags": "ruby,rails,api",
"draft": true
}

// Response: 201 Created
{
"slug": "my-new-post",
"title": "My New Post",
"excerpt": "A short excerpt",
"tags": ["ruby", "rails", "api"],
"draft": true,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-05-26T00:00:00Z",
"url": "/blog/2026/05/26/my-new-post/"
}
```

#### Update Post

```json
// PATCH /api/posts/my-new-post
{
"title": "Updated Title",
"draft": false
}

// Response: 200 OK
{
"slug": "updated-title",
"title": "Updated Title",
"excerpt": "A short excerpt",
"tags": ["ruby", "rails", "api"],
"draft": false,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-05-26T00:01:00Z",
"url": "/blog/2026/05/26/updated-title/"
}
```

#### Create Link

```json
// POST /api/links
{
"url": "https://example.com/article"
}

// Response: 201 Created
{
"id": 42,
"title": "Auto-fetched from page",
"description": "Auto-fetched meta description",
"url": "https://example.com/article",
"created_at": "2026-05-26T00:00:00Z"
}
```

#### Update Link

```json
// PATCH /api/links/42
{
"title": "Custom Title",
"description": "Custom description"
}

// Response: 200 OK
{
"id": 42,
"title": "Custom Title",
"description": "Custom description",
"url": "https://example.com/article",
"created_at": "2026-05-26T00:00:00Z"
}
```

### Draft State

Draft state is controlled via the `draft` boolean field on posts:

- `"draft": true` — post is not visible in public feeds or listings
- `"draft": false` (or omitted) — post is published and publicly visible
- Updating `draft` from `true` to `false` publishes the post
- The `published_at` timestamp should be set when a post is first published (draft → non-draft transition)

Links do not have a draft state — they are always publicly visible once created.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

published_at on posts

The RFC says:

The published_at timestamp should be set when a post is first published

The posts table today has draft, created_at, updated_atno published_at (only feed_posts have that column). Public URLs are date-based from created_at:

get "/blog/:year/:month/:day/:id/", to: "blog#show", as: "dated_post"

Please either:

  • v1: Drop published_at from the RFC and use created_at / updated_at for ordering (current behavior), and remove the checklist item as written, or
  • v2: Add a migration + document whether API clients can set created_at (backdating) and how that interacts with canonical URLs.

As written, the checklist item “Set published_at on draft → published transitions” implies schema work that isn’t specified in the RFC body.


### Error Responses

All errors return a JSON body with a descriptive message:

```json
// 401 Unauthorized
{ "error": "Invalid or missing API key" }

// 404 Not Found
{ "error": "Post not found" }

// 422 Unprocessable Entity
{ "error": "Validation failed", "details": { "title": ["can't be blank"] } }
```

## Technical Details

### API Key Model

New `ApiKey` model:

- `id` — primary key
- `user_id` — belongs to user
- `name` — human-readable label (e.g., "Hermes Agent")
- `token_digest` — SHA256 hash of the raw token (never stored plaintext)
- `last_used_at` — timestamp of last API request
- `created_at`, `expires_at` — lifecycle timestamps
- Raw token is shown only once at creation time

### Routing

API routes live under `/api` namespace in `config/routes.rb`:

```ruby
namespace :api do
resources :posts, only: %i[index show create update destroy], param: :slug
resources :links, only: %i[index create update destroy]
end
```

### Controllers

New `Api::PostsController` and `Api::LinksController` in `app/controllers/api/`. These are separate from the existing `BlogController` and `LinksController` to keep concerns separated. They share model logic but have their own rendering (JSON instead of HTML).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API design details for v1

  • Pagination: HTML uses Kaminari (paginates_per 5 on posts, 15 on links). Specify page / per_page (or cursor) on GET /api/posts and GET /api/links.
  • Content-Type: require application/json on mutating requests; return 415 if missing.
  • Errors: controllers use :unprocessable_content — align RFC examples with Rails 8 in this app.
  • Tags: document that post_tags is comma-separated and normalized like the HTML form.
  • DELETE: specify 204 No Content vs 200 + JSON for shell scripts.
  • Idempotency: agents retry POST; document duplicate-link behavior or an Idempotency-Key header for creates.


### Authentication Middleware

API authentication uses a concern similar to the existing `Authentication` concern, but checking for Bearer token instead of session cookies. The `ApiController` base class skips the `request_authentication` redirect and returns `401 JSON` instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API key model — tighten the spec

Per-user keys with token_digest and show-once raw token look good. Suggest adding to Technical Details:

  • Lookup: store a short public prefix (e.g. first 8 chars) for indexed lookup; verify with ActiveSupport::SecurityUtils.secure_compare on the digest.
  • Format: prefix tokens (e.g. abbey_…) so they are identifiable in logs and rotation.
  • Rate limiting: mirror session login (rate_limit on SessionsController) — per-IP and/or per-key on repeated 401s.
  • Revocation: revoked_at or soft-delete; admin UI in the checklist is the right place.
  • Deployment: Abbey + HomuncuCLAW use Tailscale — note whether /api is Tailscale-only, localhost + reverse proxy, or public HTTPS.

Optional auth on GET

Clarify equivalence to post_scope:

  • Bearer present → all posts (including drafts).
  • No Bearer → Post.published only.

Same rules for GET /api/posts/:slug. Decide whether unauthenticated access to a draft slug returns 404 without leaking existence.


### Existing Behavior Unchanged

- The HTML UI controllers (`BlogController`, `LinksController`) are untouched
- Public routes (`/blog/*`, `/links`, feeds) continue to work as before
- Session-based authentication for the admin UI is unchanged
- The `Post.published` scope still gates public visibility

## Implementation Checklist

- [ ] Create `ApiKey` model and migration
- [ ] Create `app/controllers/api/application_controller.rb` with Bearer token auth
- [ ] Create `app/controllers/api/posts_controller.rb` with CRUD + draft state
- [ ] Create `app/controllers/api/links_controller.rb` with CRUD
- [ ] Add API routes to `config/routes.rb`
- [ ] Add view/UI for generating and managing API keys in the admin area
- [ ] Set `published_at` on draft → published transitions
- [ ] Add tests for all API endpoints (creation, editing, draft state, auth, errors)
- [ ] Update `AGENTS.md` with new commands and API documentation