Files
StarPunk/docs/design/micropub-endpoint-design.md
Phil Skentelbery dca9604746 docs: Address Micropub design issues and clarify V1 scope
- Create ADR-029 for IndieAuth/Micropub integration strategy
- Address all critical issues from developer review:
  - Add missing 'me' parameter to token endpoint
  - Clarify PKCE as optional extension
  - Define token security migration strategy
  - Add authorization_codes table schema
  - Define property mapping rules
  - Clarify two authentication flows
- Simplify V1 scope per user decision:
  - Remove update/delete operations from V1
  - Focus on create-only functionality
  - Reduce timeline from 8-10 to 6-8 days
- Update project plan with post-V1 roadmap:
  - Phase 11: Update/delete operations (V1.1)
  - Phase 12: Media endpoint (V1.2)
  - Phase 13: Advanced IndieWeb features (V2.0)
  - Phase 14: Enhanced features (V2.0+)
- Create token security migration documentation
- Update ADR-028 for consistency with simplified scope

BREAKING CHANGE: Token migration will invalidate all existing tokens for security
2025-11-24 11:39:13 -07:00

33 KiB

Micropub Endpoint Design for StarPunk V1

Executive Summary

This document defines the architecture and implementation plan for adding Micropub support to StarPunk, the critical blocker for V1 release. The Micropub endpoint will enable external clients (like Indigenous, Quill, or Micropublish.net) to create posts on StarPunk installations.

Current State:

  • IndieAuth authentication working (authorization endpoint with PKCE)
  • Note CRUD operations via notes.py
  • Markdown file storage with YAML frontmatter
  • SQLite metadata database
  • No Micropub endpoint (V1 blocker)
  • No token endpoint (required for Micropub auth)

Target State for V1:

  • Micropub server implementation (create-only for V1)
  • IndieAuth token endpoint for issuing access tokens
  • Support for creating posts via form-encoded and JSON
  • Minimal V1 implementation (no media endpoint, no update/delete)

Architecture Overview

System Components

┌─────────────────────────────────────────────────────────────┐
│                     Micropub Client                         │
│              (Indigenous, Quill, etc.)                      │
└──────────────────────┬──────────────────────────────────────┘
                       │
                       │ HTTP Requests with Bearer Token
                       ▼
┌─────────────────────────────────────────────────────────────┐
│                    StarPunk Server                          │
│                                                              │
│  ┌────────────────────────────────────────────────────┐     │
│  │               Micropub Endpoint                    │     │
│  │              /micropub (Blueprint)                 │     │
│  ├────────────────────────────────────────────────────┤     │
│  │  • Token Validation (Bearer auth)                  │     │
│  │  • Request Parsing (form/JSON)                     │     │
│  │  • Action Routing (create/update/delete/query)     │     │
│  │  • Response Formatting                             │     │
│  └──────────────┬─────────────────────────────────────┘     │
│                 │                                            │
│                 ▼                                            │
│  ┌────────────────────────────────────────────────────┐     │
│  │              Token Management                      │     │
│  │            /auth/token (endpoint)                  │     │
│  ├────────────────────────────────────────────────────┤     │
│  │  • Authorization code exchange                     │     │
│  │  • Access token generation                         │     │
│  │  • Scope validation                                │     │
│  │  • Token storage in database                       │     │
│  └──────────────┬─────────────────────────────────────┘     │
│                 │                                            │
│                 ▼                                            │
│  ┌────────────────────────────────────────────────────┐     │
│  │           Existing Notes CRUD                      │     │
│  │             starpunk/notes.py                      │     │
│  ├────────────────────────────────────────────────────┤     │
│  │  • create_note()                                   │     │
│  │  • update_note()                                   │     │
│  │  • delete_note()                                   │     │
│  │  • get_note()                                      │     │
│  └──────────────┬─────────────────────────────────────┘     │
│                 │                                            │
│                 ▼                                            │
│  ┌────────────────────────────────────────────────────┐     │
│  │           Storage Layer                            │     │
│  ├──────────────────────┬──────────────────────────────┤     │
│  │  SQLite Database     │    Markdown Files           │     │
│  │  • notes metadata    │    /data/notes/YYYY/MM/     │     │
│  │  • tokens            │    • YAML frontmatter       │     │
│  │  • sessions          │    • Markdown content       │     │
│  └──────────────────────┴──────────────────────────────┘     │
└─────────────────────────────────────────────────────────────┘

Detailed Component Design

1. Token Endpoint (/auth/token)

The token endpoint is required for IndieAuth authorization code flow, converting authorization codes into access tokens for API access.

Endpoint Specification

POST /auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code={authorization_code}&
client_id={client_url}&
redirect_uri={redirect_url}&
me={user_profile_url}&
code_verifier={pkce_verifier}  # Optional (if PKCE was used)

Response Format

Success (200 OK):

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
  "token_type": "Bearer",
  "scope": "create update delete",
  "me": "https://example.com/"
}

Error (400 Bad Request):

{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid or expired"
}

Implementation Details

# New file: starpunk/tokens.py

def generate_access_token() -> str:
    """Generate cryptographically secure access token"""
    return secrets.token_urlsafe(32)

def create_token(me: str, client_id: str, scope: str) -> str:
    """
    Create and store access token in database

    Args:
        me: User's identity URL
        client_id: Client application URL
        scope: Space-separated list of scopes

    Returns:
        Access token string
    """
    token = generate_access_token()
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    db = get_db()
    db.execute("""
        INSERT INTO tokens (token_hash, me, client_id, scope, created_at, expires_at)
        VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+90 days'))
    """, (token_hash, me, client_id, scope))
    db.commit()

    return token

def verify_token(token: str) -> Optional[dict]:
    """
    Verify access token and return token info

    Returns dict with: me, client_id, scope, or None if invalid
    """
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    db = get_db()
    row = db.execute("""
        SELECT me, client_id, scope
        FROM tokens
        WHERE token_hash = ?
        AND expires_at > datetime('now')
    """, (token_hash,)).fetchone()

    if row:
        return dict(row)
    return None

Database Schema Update

-- SECURITY FIX: Migrate to hashed token storage
-- Note: This will invalidate all existing tokens

-- Step 1: Create new secure tokens table
CREATE TABLE tokens_secure (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    token_hash TEXT UNIQUE NOT NULL,  -- SHA256 hash of token
    me TEXT NOT NULL,
    client_id TEXT,
    scope TEXT DEFAULT 'create',  -- Default scope for V1
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    last_used_at TIMESTAMP,
    revoked_at TIMESTAMP
);

-- Step 2: Drop old insecure table (invalidates existing tokens)
DROP TABLE IF EXISTS tokens;

-- Step 3: Rename to final name
ALTER TABLE tokens_secure RENAME TO tokens;

-- Step 4: Create indexes
CREATE INDEX idx_tokens_hash ON tokens(token_hash);
CREATE INDEX idx_tokens_me ON tokens(me);
CREATE INDEX idx_tokens_expires ON tokens(expires_at);

-- Step 5: Create authorization_codes table
CREATE TABLE authorization_codes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    code_hash TEXT UNIQUE NOT NULL,  -- SHA256 hash for security
    me TEXT NOT NULL,
    client_id TEXT NOT NULL,
    redirect_uri TEXT NOT NULL,
    scope TEXT,
    state TEXT,
    code_challenge TEXT,  -- Optional PKCE
    code_challenge_method TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    used_at TIMESTAMP  -- Prevent replay attacks
);

CREATE INDEX idx_auth_codes_hash ON authorization_codes(code_hash);
CREATE INDEX idx_auth_codes_expires ON authorization_codes(expires_at);

2. Micropub Endpoint (/micropub)

The main Micropub endpoint handles all post operations.

Route Structure

# New file: starpunk/routes/micropub.py

from flask import Blueprint, request, jsonify, current_app
from starpunk.tokens import verify_token
from starpunk.micropub import (
    handle_create,
    handle_update,
    handle_delete,
    handle_query,
    MicropubError
)

bp = Blueprint("micropub", __name__)

@bp.route("/micropub", methods=["GET", "POST"])
def micropub_endpoint():
    """Main Micropub endpoint"""

    # Extract token from Authorization header or form
    token = extract_bearer_token(request)
    if not token:
        return error_response("unauthorized", "No access token provided"), 401

    # Verify token
    token_info = verify_token(token)
    if not token_info:
        return error_response("unauthorized", "Invalid access token"), 401

    # Handle query endpoints (GET requests)
    if request.method == "GET":
        return handle_query(request.args, token_info)

    # Handle action endpoints (POST requests)
    content_type = request.headers.get("Content-Type", "")

    if "application/json" in content_type:
        data = request.get_json()
        action = data.get("action", "create")
    else:
        # Form-encoded or multipart
        data = request.form.to_dict(flat=False)
        action = data.get("action", ["create"])[0]

    try:
        if action == "create":
            return handle_create(data, token_info)
        elif action == "update":
            # V1: Update not supported
            return error_response("invalid_request", "Update action not supported in V1"), 400
        elif action == "delete":
            # V1: Delete not supported
            return error_response("invalid_request", "Delete action not supported in V1"), 400
        else:
            return error_response("invalid_request", f"Unknown action: {action}"), 400
    except MicropubError as e:
        return error_response("invalid_request", str(e)), 400

Content Creation Handler

# New file: starpunk/micropub.py

def handle_create(data: dict, token_info: dict) -> tuple:
    """
    Handle post creation

    Args:
        data: Micropub request data (form or JSON)
        token_info: Authenticated user info

    Returns:
        Response tuple (body, status, headers)
    """
    # Check scope
    if "create" not in token_info.get("scope", ""):
        return error_response("insufficient_scope", "Token lacks create scope"), 403

    # Extract properties
    properties = normalize_properties(data)

    # Map Micropub properties to StarPunk note format
    title = extract_title(properties)
    content = extract_content(properties)
    tags = properties.get("category", [])

    # Create note using existing CRUD
    from starpunk.notes import create_note

    try:
        note = create_note(
            title=title,
            content=content,
            tags=tags,
            author=token_info["me"],
            published=True  # Micropub posts are published by default
        )

        # Build permalink URL
        permalink = f"{current_app.config['SITE_URL']}/notes/{note.slug}"

        # Return 201 Created with Location header
        return "", 201, {"Location": permalink}

    except Exception as e:
        current_app.logger.error(f"Failed to create note via Micropub: {e}")
        return error_response("server_error", "Failed to create post"), 500

def normalize_properties(data: dict) -> dict:
    """
    Normalize Micropub properties from both form and JSON formats

    Handles:
    - Form: property[]=value arrays
    - JSON: {"properties": {"property": ["value"]}}
    """
    if "properties" in data:
        # JSON format
        return data["properties"]

    # Form format - convert to properties dict
    properties = {}
    for key, value in data.items():
        if key.startswith("mp-") or key in ["action", "url", "access_token"]:
            continue  # Skip reserved properties

        # Handle array notation: property[] -> property
        clean_key = key.rstrip("[]")

        # Ensure value is always a list
        if not isinstance(value, list):
            value = [value]

        properties[clean_key] = value

    return properties

def extract_content(properties: dict) -> str:
    """Extract content from Micropub properties"""
    content = properties.get('content', [''])[0]
    if not content:
        raise MicropubError("Content is required")
    return content

def extract_title(properties: dict) -> str:
    """Extract title from Micropub properties"""
    # Use 'name' property if provided
    title = properties.get('name', [''])[0]

    # If no name, extract from content (first line, max 50 chars)
    if not title:
        content = properties.get('content', [''])[0]
        if content:
            first_line = content.split('\n')[0]
            title = first_line[:50] + ('...' if len(first_line) > 50 else '')

    return title

V1 Limitations

In V1, only the create action is supported. Update and delete operations will return an error response and are planned for a post-V1 release.

3. Query Endpoints

Micropub defines several query endpoints for client discovery:

def handle_query(args: dict, token_info: dict) -> tuple:
    """Handle Micropub query endpoints"""
    q = args.get("q")

    if q == "config":
        # Return server configuration
        config = {
            "media-endpoint": None,  # No media endpoint in V1
            "syndicate-to": [],      # No syndication targets in V1
            "post-types": [
                {
                    "type": "note",
                    "name": "Note",
                    "properties": ["content"]
                }
            ]
        }
        return jsonify(config), 200

    elif q == "source":
        # Return source of a specific post
        url = args.get("url")
        if not url:
            return error_response("invalid_request", "No URL provided"), 400

        slug = extract_slug_from_url(url)
        from starpunk.notes import get_note

        note = get_note(slug)
        if not note:
            return error_response("invalid_request", "Post not found"), 400

        # Convert note to Micropub format
        mf2 = {
            "type": ["h-entry"],
            "properties": {
                "content": [note.content],
                "name": [note.title] if note.title else [],
                "category": note.tags if note.tags else [],
                "published": [note.created_at.isoformat()],
                "url": [f"{current_app.config['SITE_URL']}/notes/{note.slug}"]
            }
        }

        return jsonify(mf2), 200

    elif q == "syndicate-to":
        # Return syndication targets (none for V1)
        return jsonify({"syndicate-to": []}), 200

    else:
        return error_response("invalid_request", f"Unknown query: {q}"), 400

4. Authorization Flow Integration

The complete authorization flow for Micropub:

┌──────────┐
│  Client  │
└─────┬────┘
      │
      │ 1. GET /auth/authorization?
      │    response_type=code&
      │    client_id=https://client.example&
      │    redirect_uri=https://client.example/callback&
      │    state=1234567890&
      │    scope=create&  # V1: create only
      │    me=https://user.example&
      │    code_challenge=XXXXX&  # Optional PKCE
      │    code_challenge_method=S256
      ▼
┌──────────────┐
│  StarPunk    │
│ (Authorize)  │
└─────┬────────┘
      │
      │ 2. Check admin session
      │    If not logged in: redirect to /auth/login
      │    If logged in: show authorization form
      │
      │ 3. User approves, generate authorization code
      │
      ▼
┌──────────┐
│  Client  │
└─────┬────┘
      │
      │ 4. POST /auth/token
      │    grant_type=authorization_code&
      │    code=XXXXX&
      │    client_id=https://client.example&
      │    redirect_uri=https://client.example/callback&
      │    me=https://user.example&
      │    code_verifier=XXXXX  # If PKCE was used
      ▼
┌──────────────┐
│  StarPunk    │
│   (Token)    │
└─────┬────────┘
      │
      │ 5. Return access token
      │    {
      │      "access_token": "XXXXX",
      │      "token_type": "Bearer",
      │      "scope": "create",  # V1: create only
      │      "me": "https://user.example"
      │    }
      ▼
┌──────────┐
│  Client  │
└─────┬────┘
      │
      │ 6. POST /micropub
      │    Authorization: Bearer XXXXX
      │    Content-Type: application/x-www-form-urlencoded
      │
      │    h=entry&content=Hello+world
      ▼
┌──────────────┐
│  StarPunk    │
│  (Micropub)  │
└──────────────┘

Implementation Plan

Phase 1: Token Management (2-3 days)

  1. Database Migration

    • Update tokens table schema
    • Add indexes for performance
    • Create migration script
  2. Token Module (starpunk/tokens.py)

    • Token generation functions
    • Token storage with hashing
    • Token verification
    • Scope validation helpers
  3. Token Endpoint (/auth/token)

    • Authorization code validation
    • PKCE verification
    • Token generation
    • Response formatting
  4. Testing

    • Unit tests for token functions
    • Integration tests for token endpoint
    • Security tests (token expiry, revocation)

Phase 2: Micropub Core (3-4 days)

  1. Micropub Module (starpunk/micropub.py)

    • Property normalization
    • Content extraction
    • Note format conversion
    • Error handling
  2. Micropub Routes (starpunk/routes/micropub.py)

    • Main endpoint handler
    • Bearer token extraction
    • Content-type handling
    • Action routing
  3. Create Handler

    • Form-encoded parsing
    • JSON parsing
    • Note creation via CRUD
    • Location header generation
  4. Testing

    • Create post via form-encoded
    • Create post via JSON
    • Invalid token handling
    • Scope validation

Phase 3: Query Endpoints & Validation (1-2 days)

  1. Query Endpoints

    • Config endpoint
    • Source endpoint
    • Syndicate-to endpoint (empty for V1)
  2. Property Validation

    • Content extraction and validation
    • Title generation from content
    • Tag/category mapping
  3. Testing

    • Query endpoint responses
    • Property mapping edge cases
    • Error response formatting

Phase 4: Integration & Polish (1-2 days)

  1. Authorization Endpoint Updates

    • Add scope parameter handling
    • Store requested scopes with auth state
    • Pass scopes to token creation
  2. Discovery Headers

    • Add Link: <https://site/micropub>; rel="micropub" header
    • Add Link: <https://site/auth/authorization>; rel="authorization_endpoint" header
    • Add Link: <https://site/auth/token>; rel="token_endpoint" header
  3. Error Handling

    • Consistent error responses
    • Proper HTTP status codes
    • Detailed error descriptions
  4. Documentation

    • API documentation
    • Client configuration guide
    • Testing with real clients

V1 Minimal Feature Set

In Scope for V1

Required for Micropub Compliance:

  • Bearer token authentication
  • Create posts (form-encoded)
  • Create posts (JSON)
  • Return 201 Created with Location header
  • Configuration query endpoint
  • Source query endpoint
  • Authorization endpoint (/auth/authorization)
  • Token endpoint (/auth/token)

Required for Functionality:

  • Authorization code generation and storage
  • Token generation with SHA256 hashing
  • Scope enforcement (create only for V1)
  • Integration with existing notes CRUD
  • Property mapping (content, title, tags)
  • Optional PKCE support

Out of Scope for V1 (Post-V1 Roadmap)

Deferred to Post-V1:

  • Update operations (action=update)
  • Delete operations (action=delete)
  • Media endpoint (file uploads)
  • Photo/video properties
  • Syndication targets
  • Complex post types (articles, replies, etc.)
  • Batch operations
  • Websub notifications
  • Token introspection endpoint
  • Token revocation endpoint
  • Multiple scopes beyond "create"

Security Considerations

Token Security

  1. Storage

    • Tokens stored as SHA256 hashes
    • Original token never logged
    • Secure random generation (32 bytes)
  2. Validation

    • Check token exists
    • Check not expired (90 days)
    • Check not revoked
    • Verify scope for operation
  3. Transport

    • Require HTTPS in production
    • Support both header and form parameter
    • Never include in URLs

Scope Enforcement

# V1 only supports "create" scope
SUPPORTED_SCOPES = ["create"]
DEFAULT_SCOPE = "create"

def check_scope(required: str, granted: str) -> bool:
    """Check if granted scopes include required scope"""
    if not granted:
        # IndieAuth spec: no scope means no access
        return False
    granted_scopes = set(granted.split())
    return required in granted_scopes

def validate_requested_scope(scope: str) -> str:
    """Validate and filter requested scopes to supported ones"""
    if not scope:
        return ""  # Empty scope allowed during authorization

    requested = set(scope.split())
    supported = set(SUPPORTED_SCOPES)
    valid_scopes = requested & supported

    return " ".join(valid_scopes) if valid_scopes else ""

Input Validation

  1. Content Validation

    • Sanitize HTML content
    • Validate URLs
    • Limit content size
    • Check required properties
  2. URL Validation

    • Verify URL belongs to this site
    • Extract valid slugs
    • Prevent directory traversal
  3. Rate Limiting

    • Limit requests per token
    • Limit failed authentication attempts
    • Implement exponential backoff

Testing Strategy

Unit Tests

# tests/test_micropub.py

def test_normalize_properties_form():
    """Test form-encoded property normalization"""
    data = {
        "content": ["Hello world"],
        "category[]": ["tag1", "tag2"],
        "mp-slug": ["my-post"]
    }

    properties = normalize_properties(data)

    assert properties["content"] == ["Hello world"]
    assert properties["category"] == ["tag1", "tag2"]
    assert "mp-slug" not in properties

def test_normalize_properties_json():
    """Test JSON property normalization"""
    data = {
        "type": ["h-entry"],
        "properties": {
            "content": ["Hello world"],
            "category": ["tag1", "tag2"]
        }
    }

    properties = normalize_properties(data)

    assert properties["content"] == ["Hello world"]
    assert properties["category"] == ["tag1", "tag2"]

def test_bearer_token_extraction():
    """Test token extraction from header and form"""
    # Test header
    request = Mock(headers={"Authorization": "Bearer abc123"})
    token = extract_bearer_token(request)
    assert token == "abc123"

    # Test form parameter
    request = Mock(headers={}, form={"access_token": "xyz789"})
    token = extract_bearer_token(request)
    assert token == "xyz789"

Integration Tests

# tests/test_micropub_integration.py

def test_create_post_form_encoded(client, auth_token):
    """Test creating post with form-encoded request"""
    response = client.post("/micropub",
        headers={"Authorization": f"Bearer {auth_token}"},
        data={
            "h": "entry",
            "content": "Test post",
            "category[]": ["test", "micropub"]
        }
    )

    assert response.status_code == 201
    assert "Location" in response.headers
    assert "/notes/" in response.headers["Location"]

def test_create_post_json(client, auth_token):
    """Test creating post with JSON request"""
    response = client.post("/micropub",
        headers={
            "Authorization": f"Bearer {auth_token}",
            "Content-Type": "application/json"
        },
        json={
            "type": ["h-entry"],
            "properties": {
                "content": ["Test post"],
                "category": ["test", "micropub"]
            }
        }
    )

    assert response.status_code == 201
    assert "Location" in response.headers

def test_unauthorized_request(client):
    """Test request without token"""
    response = client.post("/micropub",
        data={"h": "entry", "content": "Test"}
    )

    assert response.status_code == 401
    assert response.json["error"] == "unauthorized"

def test_insufficient_scope(client, read_only_token):
    """Test request with insufficient scope"""
    response = client.post("/micropub",
        headers={"Authorization": f"Bearer {read_only_token}"},
        data={"h": "entry", "content": "Test"}
    )

    assert response.status_code == 403
    assert response.json["error"] == "insufficient_scope"

Client Testing

Test with real Micropub clients:

  1. Indigenous (iOS/Android)

    • Configure with StarPunk URLs
    • Test post creation
    • Test photo uploads (when media endpoint added)
  2. Quill (Web)

  3. Micropublish.net (Web)

Migration Path

Database Migrations

-- Migration: Add Micropub support
-- Version: 0.10.0

-- 1. Update tokens table for better security
ALTER TABLE tokens RENAME TO tokens_old;

CREATE TABLE tokens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    token_hash TEXT UNIQUE NOT NULL,
    me TEXT NOT NULL,
    client_id TEXT,
    scope TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP,
    last_used_at TIMESTAMP,
    revoked_at TIMESTAMP
);

-- Migrate existing tokens (if any)
-- Note: Existing tokens will be invalid as we don't have original values

DROP TABLE tokens_old;

-- 2. Add authorization codes table for token exchange
CREATE TABLE IF NOT EXISTS authorization_codes (
    code TEXT PRIMARY KEY,
    me TEXT NOT NULL,
    client_id TEXT NOT NULL,
    redirect_uri TEXT NOT NULL,
    scope TEXT,
    code_challenge TEXT,
    code_challenge_method TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    used_at TIMESTAMP
);

CREATE INDEX idx_auth_codes_expires ON authorization_codes(expires_at);

Configuration Updates

# config.py additions

# Micropub settings
MICROPUB_ENDPOINT = "/micropub"
TOKEN_ENDPOINT = "/auth/token"
TOKEN_EXPIRY_DAYS = 90
AUTHORIZATION_CODE_EXPIRY_MINUTES = 10

# Supported scopes (V1 only supports create)
SUPPORTED_SCOPES = ["create"]
DEFAULT_SCOPE = "create"

Effort Estimates

Total Effort: 6-8 days (Reduced for V1 scope)

Phase Task Effort Priority
1 Token Management 2-3 days Critical
2 Micropub Core (Create Only) 2-3 days Critical
3 Query Endpoints & Validation 1-2 days High
4 Integration & Polish 1-2 days High

Resource Requirements

  • Developer: 1 person full-time for 2 weeks
  • Testing: Access to Micropub clients
  • Documentation: 1 day included in estimates

Risk Factors

  1. Token Security (High Impact, Low Probability)

    • Mitigation: Follow security best practices, hash tokens
  2. Client Compatibility (Medium Impact, Medium Probability)

    • Mitigation: Test with multiple clients early
  3. Scope Creep (Medium Impact, High Probability)

    • Mitigation: Strict V1 feature set, defer nice-to-haves

Success Criteria

Functional Requirements

Micropub endpoint responds to POST requests Bearer token authentication works Posts can be created via form-encoded requests Posts can be created via JSON requests Location header returned on creation Query endpoints return valid responses Token endpoint exchanges codes for tokens Scopes are enforced correctly

Performance Requirements

Post creation < 500ms Token validation < 50ms Query responses < 200ms

Security Requirements

Tokens stored as hashes Expired tokens rejected Invalid tokens return 401 Insufficient scope returns 403 HTTPS required in production

Compatibility Requirements

Works with Indigenous app Works with Quill web client Works with Micropublish.net Passes Micropub Rocks validator

Conclusion

The Micropub implementation is straightforward given StarPunk's existing architecture:

  1. Existing CRUD operations in notes.py handle the storage
  2. Existing authentication provides the foundation for tokens
  3. Clear specification from W3C defines exact requirements
  4. Minimal V1 scope focuses on core functionality

The main work is:

  • Building the token endpoint for IndieAuth
  • Creating the Micropub request handler
  • Mapping between Micropub and StarPunk formats
  • Proper error handling and responses

With 8-10 days of focused development, StarPunk will have a fully functional Micropub endpoint, unblocking the V1 release.

Appendix: Example Requests

Creating a Note (Form-Encoded)

POST /micropub HTTP/1.1
Host: starpunk.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
Content-Type: application/x-www-form-urlencoded

h=entry&
content=Just+had+coffee+at+the+new+place+downtown.+Really+good!&
category[]=coffee&
category[]=portland

Creating a Note (JSON)

POST /micropub HTTP/1.1
Host: starpunk.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
Content-Type: application/json

{
  "type": ["h-entry"],
  "properties": {
    "content": ["Just had coffee at the new place downtown. Really good!"],
    "category": ["coffee", "portland"]
  }
}

V1 Limitation Examples

Attempting Update (Returns Error)

POST /micropub HTTP/1.1
Host: starpunk.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
Content-Type: application/json

{
  "action": "update",
  "url": "https://starpunk.example.com/notes/2024/11/coffee-downtown",
  "replace": {
    "content": ["Updated content"]
  }
}

Response (400 Bad Request):

{
  "error": "invalid_request",
  "error_description": "Update action not supported in V1"
}

Attempting Delete (Returns Error)

POST /micropub HTTP/1.1
Host: starpunk.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
Content-Type: application/x-www-form-urlencoded

action=delete&
url=https://starpunk.example.com/notes/2024/11/test-post

Response (400 Bad Request):

{
  "error": "invalid_request",
  "error_description": "Delete action not supported in V1"
}

Querying Configuration

GET /micropub?q=config HTTP/1.1
Host: starpunk.example.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...

Response:

{
  "media-endpoint": null,
  "syndicate-to": [],
  "post-types": [
    {
      "type": "note",
      "name": "Note",
      "properties": ["content"]
    }
  ]
}