docs: Add v1.1.0 architecture and validation documentation

- ADR-033: Database migration redesign
- ADR-034: Full-text search with FTS5
- ADR-035: Custom slugs in Micropub
- ADR-036: IndieAuth token verification method
- ADR-039: Micropub URL construction fix
- Implementation plan and decisions
- Architecture specifications
- Validation reports for implementation and search UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 10:39:58 -07:00
parent 8f71ff36ec
commit 82bb1499d5
13 changed files with 3324 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
# ADR-033: Database Migration System Redesign
## Status
Proposed
## Context
The current migration system has a critical flaw: duplicate schema definitions exist between SCHEMA_SQL (used for fresh installs) and individual migration files. This violates the DRY principle and creates maintenance burden. When schema changes are made, developers must remember to update both locations, leading to potential inconsistencies.
Current problems:
1. Duplicate schema definitions in SCHEMA_SQL and migration files
2. Risk of schema drift between fresh installs and upgraded databases
3. Maintenance overhead of keeping two schema sources in sync
4. Confusion about which schema definition is authoritative
## Decision
Implement an INITIAL_SCHEMA_SQL approach where:
1. **Single Source of Truth**: The initial schema (v1.0.0 state) is defined once in INITIAL_SCHEMA_SQL
2. **Migration-Only Changes**: All schema changes after v1.0.0 are defined only in migration files
3. **Fresh Install Path**: New installations run INITIAL_SCHEMA_SQL + all migrations in sequence
4. **Upgrade Path**: Existing installations only run new migrations from their current version
5. **Version Tracking**: The migrations table continues to track applied migrations
6. **Lightweight System**: Maintain custom migration system without heavyweight ORMs
Implementation approach:
```python
# Conceptual flow (not actual code)
def initialize_database():
if is_fresh_install():
execute(INITIAL_SCHEMA_SQL) # v1.0.0 schema
mark_initial_version()
apply_pending_migrations() # Apply any migrations after v1.0.0
```
## Rationale
This approach provides several benefits:
1. **DRY Compliance**: Schema for any version is defined exactly once
2. **Clear History**: Migration files form a clear changelog of schema evolution
3. **Reduced Errors**: No risk of forgetting to update duplicate definitions
4. **Maintainability**: Easier to understand what changed when
5. **Simplicity**: Still lightweight, no heavy dependencies
6. **Compatibility**: Works with existing migration infrastructure
Alternative approaches considered:
- **SQLAlchemy/Alembic**: Too heavyweight for a minimal CMS
- **Django-style migrations**: Requires ORM, adds complexity
- **Status quo**: Maintaining duplicate schemas is error-prone
- **Single evolving schema file**: Loses history of changes
## Consequences
### Positive
- Single source of truth for each schema state
- Clear separation between initial schema and evolution
- Easier onboarding for new developers
- Reduced maintenance burden
- Better documentation of schema evolution
### Negative
- One-time migration to new system required
- Must carefully preserve v1.0.0 schema state in INITIAL_SCHEMA_SQL
- Fresh installs run more SQL statements (initial + migrations)
### Implementation Requirements
1. Extract current v1.0.0 schema to INITIAL_SCHEMA_SQL
2. Remove schema definitions from existing migration files
3. Update migration runner to handle initial schema
4. Test both fresh install and upgrade paths thoroughly
5. Document the new approach clearly
## Alternatives Considered
### Alternative 1: SQLAlchemy/Alembic
- **Pros**: Industry standard, automatic migration generation
- **Cons**: Heavy dependency, requires ORM adoption, against minimal philosophy
- **Rejected because**: Overkill for single-table schema
### Alternative 2: Single Evolving Schema File
- **Pros**: Simple, one file to maintain
- **Cons**: No history, can't track changes, upgrade path unclear
- **Rejected because**: Loses important schema evolution history
### Alternative 3: Status Quo (Duplicate Schemas)
- **Pros**: Already implemented, works currently
- **Cons**: DRY violation, error-prone, maintenance burden
- **Rejected because**: Technical debt will compound over time
## Migration Plan
1. **Phase 1**: Document exact v1.0.0 schema state
2. **Phase 2**: Create INITIAL_SCHEMA_SQL from current state
3. **Phase 3**: Refactor migration system to use new approach
4. **Phase 4**: Test extensively with both paths
5. **Phase 5**: Deploy in v1.1.0 with clear upgrade instructions
## References
- ADR-032: Migration Requirements (parent decision)
- Issue: Database schema duplication
- Similar approach: Rails migrations with schema.rb

View File

@@ -0,0 +1,186 @@
# ADR-034: Full-Text Search with SQLite FTS5
## Status
Proposed
## Context
Users need the ability to search through their notes efficiently. Currently, finding specific content requires manually browsing through notes or using external tools. A built-in search capability is essential for any content management system, especially as the number of notes grows.
Requirements:
- Fast search across all note content
- Support for phrase searching and boolean operators
- Ranking by relevance
- Minimal performance impact on write operations
- No external dependencies (Elasticsearch, Solr, etc.)
- Works with existing SQLite database
## Decision
Implement full-text search using SQLite's FTS5 (Full-Text Search version 5) extension:
1. **FTS5 Virtual Table**: Create a shadow FTS table that indexes note content
2. **Synchronized Updates**: Keep FTS index in sync with note operations
3. **Search Endpoint**: New `/api/search` endpoint for queries
4. **Search UI**: Simple search interface in the web UI
5. **Advanced Operators**: Support FTS5's query syntax for power users
Database schema:
```sql
-- FTS5 virtual table for note content
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
slug UNINDEXED, -- For result retrieval, not searchable
title, -- Note title (first line)
content, -- Full markdown content
tokenize='porter unicode61' -- Stem words, handle unicode
);
-- Trigger to keep FTS in sync with notes table
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes
BEGIN
INSERT INTO notes_fts (rowid, slug, title, content)
SELECT id, slug, title_from_content(content), content
FROM notes WHERE id = NEW.id;
END;
-- Similar triggers for UPDATE and DELETE
```
## Rationale
SQLite FTS5 is the optimal choice because:
1. **Native Integration**: Built into SQLite, no external dependencies
2. **Performance**: Highly optimized C implementation
3. **Features**: Rich query syntax (phrases, NEAR, boolean, wildcards)
4. **Ranking**: Built-in BM25 ranking algorithm
5. **Simplicity**: Just another table in our existing database
6. **Maintenance-free**: No separate search service to manage
7. **Size**: Minimal storage overhead (~30% of original text)
Query capabilities:
- Simple terms: `indieweb`
- Phrases: `"static site"`
- Wildcards: `micro*`
- Boolean: `micropub OR websub`
- Exclusions: `indieweb NOT wordpress`
- Field-specific: `title:announcement`
## Consequences
### Positive
- Powerful search with zero external dependencies
- Fast queries even with thousands of notes
- Rich query syntax for power users
- Automatic stemming (search "running" finds "run", "runs")
- Unicode support for international content
- Integrates seamlessly with existing SQLite database
### Negative
- FTS index increases database size by ~30%
- Initial indexing of existing notes required
- Must maintain sync triggers for consistency
- FTS5 requires SQLite 3.9.0+ (2015, widely available)
- Cannot search in encrypted/binary content
### Performance Characteristics
- Index build: ~1ms per note
- Search query: <10ms for 10,000 notes
- Index size: ~30% of indexed text
- Write overhead: ~5% increase in note creation time
## Alternatives Considered
### Alternative 1: Simple LIKE Queries
```sql
SELECT * FROM notes WHERE content LIKE '%search term%'
```
- **Pros**: No setup, works today
- **Cons**: Extremely slow on large datasets, no ranking, no advanced features
- **Rejected because**: Performance degrades quickly with scale
### Alternative 2: External Search Service (Elasticsearch/Meilisearch)
- **Pros**: More features, dedicated search infrastructure
- **Cons**: External dependency, complex setup, overkill for single-user CMS
- **Rejected because**: Violates minimal philosophy, adds operational complexity
### Alternative 3: Client-Side Search (Lunr.js)
- **Pros**: No server changes needed
- **Cons**: Must download all content to browser, doesn't scale
- **Rejected because**: Impractical beyond a few hundred notes
### Alternative 4: Regex/Grep-based Search
- **Pros**: Powerful pattern matching
- **Cons**: Slow, no ranking, must read all files from disk
- **Rejected because**: Poor performance, no relevance ranking
## Implementation Plan
### Phase 1: Database Schema (2 hours)
1. Add FTS5 table creation to migrations
2. Create sync triggers for INSERT/UPDATE/DELETE
3. Build initial index from existing notes
4. Test sync on note operations
### Phase 2: Search API (2 hours)
1. Create `/api/search` endpoint
2. Implement query parser and validation
3. Add result ranking and pagination
4. Return structured results with snippets
### Phase 3: Search UI (1 hour)
1. Add search box to navigation
2. Create search results page
3. Highlight matching terms in results
4. Add search query syntax help
### Phase 4: Testing (1 hour)
1. Test with various query types
2. Benchmark with large datasets
3. Verify sync triggers work correctly
4. Test Unicode and special characters
## API Design
### Search Endpoint
```
GET /api/search?q={query}&limit=20&offset=0
Response:
{
"query": "indieweb micropub",
"total": 15,
"results": [
{
"slug": "implementing-micropub",
"title": "Implementing Micropub",
"snippet": "...the <mark>IndieWeb</mark> <mark>Micropub</mark> specification...",
"rank": 2.4,
"published": true,
"created_at": "2024-01-15T10:00:00Z"
}
]
}
```
### Query Syntax Examples
- `indieweb` - Find notes containing "indieweb"
- `"static site"` - Exact phrase
- `micro*` - Prefix search
- `title:announcement` - Search in title only
- `micropub OR websub` - Boolean operators
- `indieweb -wordpress` - Exclusion
## Security Considerations
1. Sanitize queries to prevent SQL injection (FTS5 handles this)
2. Rate limit search endpoint to prevent abuse
3. Only search published notes for anonymous users
4. Escape HTML in snippets to prevent XSS
## Migration Strategy
1. Check SQLite version supports FTS5 (3.9.0+)
2. Create FTS table and triggers in migration
3. Build initial index from existing notes
4. Monitor index size and performance
5. Document search syntax for users
## References
- SQLite FTS5 Documentation: https://www.sqlite.org/fts5.html
- BM25 Ranking: https://en.wikipedia.org/wiki/Okapi_BM25
- FTS5 Performance: https://www.sqlite.org/fts5.html#performance

View File

@@ -0,0 +1,204 @@
# ADR-035: Custom Slugs in Micropub
## Status
Proposed
## Context
Currently, StarPunk auto-generates slugs from note content (first 5 words). While this works well for most cases, users may want to specify custom slugs for:
- SEO-friendly URLs
- Memorable short links
- Maintaining URL structure from migrated content
- Creating hierarchical paths (e.g., `2024/11/my-note`)
- Personal preference and control
The Micropub specification supports custom slugs via the `mp-slug` property, which we should honor.
## Decision
Implement custom slug support through the Micropub endpoint:
1. **Accept mp-slug**: Process the `mp-slug` property in Micropub requests
2. **Validation**: Ensure slugs are URL-safe and unique
3. **Fallback**: Auto-generate if no slug provided or if invalid
4. **Conflict Resolution**: Handle duplicate slugs gracefully
5. **Character Restrictions**: Allow only URL-safe characters
Implementation approach:
```python
def process_micropub_request(request_data):
# Extract custom slug if provided
custom_slug = request_data.get('properties', {}).get('mp-slug', [None])[0]
if custom_slug:
# Validate and sanitize
slug = sanitize_slug(custom_slug)
# Ensure uniqueness
if slug_exists(slug):
# Add suffix or reject based on configuration
slug = make_unique(slug)
else:
# Fall back to auto-generation
slug = generate_slug(content)
return create_note(content, slug=slug)
```
## Rationale
Supporting custom slugs provides:
1. **User Control**: Authors can define meaningful URLs
2. **Standards Compliance**: Follows Micropub specification
3. **Migration Support**: Easier to preserve URLs when migrating
4. **SEO Benefits**: Human-readable URLs improve discoverability
5. **Flexibility**: Accommodates different URL strategies
6. **Backward Compatible**: Existing auto-generation continues working
Validation rules:
- Maximum length: 200 characters
- Allowed characters: `a-z0-9-_/`
- No consecutive slashes or dashes
- No leading/trailing special characters
- Case-insensitive uniqueness check
## Consequences
### Positive
- Full Micropub compliance for slug handling
- Better user experience and control
- SEO-friendly URLs when desired
- Easier content migration from other platforms
- Maintains backward compatibility
### Negative
- Additional validation complexity
- Potential for user confusion with conflicts
- Must handle edge cases (empty, invalid, duplicate)
- Slightly more complex note creation logic
### Security Considerations
1. **Path Traversal**: Reject slugs containing `..` or absolute paths
2. **Reserved Names**: Block system routes (`api`, `admin`, `feed`, etc.)
3. **Length Limits**: Enforce maximum slug length
4. **Character Filtering**: Strip or reject dangerous characters
5. **Case Sensitivity**: Normalize to lowercase for consistency
## Alternatives Considered
### Alternative 1: No Custom Slugs
- **Pros**: Simpler, no validation needed
- **Cons**: Poor user experience, non-compliant with Micropub
- **Rejected because**: Users expect URL control in modern CMS
### Alternative 2: Separate Slug Field in UI
- **Pros**: More discoverable for web users
- **Cons**: Doesn't help API users, not Micropub standard
- **Rejected because**: Should follow established standards
### Alternative 3: Slugs Only via Direct API
- **Pros**: Advanced feature for power users only
- **Cons**: Inconsistent experience, limits adoption
- **Rejected because**: Micropub clients expect this feature
### Alternative 4: Hierarchical Slugs (`/2024/11/25/my-note`)
- **Pros**: Organized structure, date-based archives
- **Cons**: Complex routing, harder to implement
- **Rejected because**: Can add later if needed, start simple
## Implementation Plan
### Phase 1: Core Logic (2 hours)
1. Modify note creation to accept optional slug parameter
2. Implement slug validation and sanitization
3. Add uniqueness checking with conflict resolution
4. Update database schema if needed (no changes expected)
### Phase 2: Micropub Integration (1 hour)
1. Extract `mp-slug` from Micropub requests
2. Pass to note creation function
3. Handle validation errors appropriately
4. Return proper Micropub responses
### Phase 3: Testing (1 hour)
1. Test valid custom slugs
2. Test invalid characters and patterns
3. Test duplicate slug handling
4. Test with Micropub clients
5. Test auto-generation fallback
## Validation Specification
### Allowed Slug Format
```regex
^[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*$
```
Examples:
-`my-awesome-post`
-`2024/11/25/daily-note`
-`projects/starpunk/update-1`
-`My-Post` (uppercase)
-`my--post` (consecutive dashes)
-`-my-post` (leading dash)
-`my_post` (underscore not allowed)
-`../../../etc/passwd` (path traversal)
### Reserved Slugs
The following slugs are reserved and cannot be used:
- System routes: `api`, `admin`, `auth`, `feed`, `static`
- Special pages: `login`, `logout`, `settings`
- File extensions: Slugs ending in `.xml`, `.json`, `.html`
### Conflict Resolution Strategy
When a duplicate slug is detected:
1. Append `-2`, `-3`, etc. to make unique
2. Check up to `-99` before failing
3. Return error if no unique slug found in 99 attempts
Example:
- Request: `mp-slug=my-note`
- Exists: `my-note`
- Created: `my-note-2`
## API Examples
### Micropub Request with Custom Slug
```http
POST /micropub
Content-Type: application/json
Authorization: Bearer {token}
{
"type": ["h-entry"],
"properties": {
"content": ["My awesome post content"],
"mp-slug": ["my-awesome-post"]
}
}
```
### Response
```http
HTTP/1.1 201 Created
Location: https://example.com/note/my-awesome-post
```
### Invalid Slug Handling
```http
HTTP/1.1 400 Bad Request
Content-Type: application/json
```
## Migration Notes
1. Existing notes keep their auto-generated slugs
2. No database migration required (slug field exists)
3. No breaking changes to API
4. Existing clients continue working without modification
## References
- Micropub Specification: https://www.w3.org/TR/micropub/#mp-slug
- URL Slug Best Practices: https://stackoverflow.com/questions/695438/safe-characters-for-friendly-url
- IndieWeb Slug Examples: https://indieweb.org/slug
## References
- Micropub Specification: https://www.w3.org/TR/micropub/#mp-slug
- URL Slug Best Practices: https://stackoverflow.com/questions/695438/safe-characters-for-friendly-url
- IndieWeb Slug Examples: https://indieweb.org/slug

View File

@@ -0,0 +1,114 @@
# ADR-036: IndieAuth Token Verification Method Diagnosis
## Status
Accepted
## Context
StarPunk is experiencing HTTP 405 Method Not Allowed errors when verifying tokens with the external IndieAuth provider (gondulf.thesatelliteoflove.com). The user questioned "why are we making GET requests to these endpoints?"
Error from logs:
```
[2025-11-25 03:29:50] WARNING: Token verification failed:
Verification failed: Unexpected response: HTTP 405
```
## Investigation Results
### What the IndieAuth Spec Says
According to the W3C IndieAuth specification (Section 6.3.4 - Token Verification):
- Token verification MUST use a **GET request** to the token endpoint
- The request must include an Authorization header with Bearer token format
- This is explicitly different from token issuance, which uses POST
### What Our Code Does
Our implementation in `starpunk/auth_external.py` (line 425):
- **Correctly** uses GET for token verification
- **Correctly** sends Authorization: Bearer header
- **Correctly** follows the IndieAuth specification
### Why the 405 Error Occurs
HTTP 405 Method Not Allowed means the server doesn't support the HTTP method (GET) for the requested resource. This indicates that the gondulf IndieAuth provider is **not implementing the IndieAuth specification correctly**.
## Decision
Our implementation is correct. We are making GET requests because:
1. The IndieAuth spec explicitly requires GET for token verification
2. This distinguishes verification (GET) from token issuance (POST)
3. This is a standard pattern in OAuth-like protocols
## Rationale
### Why GET for Verification?
The IndieAuth spec uses different HTTP methods for different operations:
- **POST** for state-changing operations (issuing tokens, revoking tokens)
- **GET** for read-only operations (verifying tokens)
This follows RESTful principles where:
- GET is idempotent and safe (doesn't modify server state)
- POST creates or modifies resources
### The Problem
The gondulf IndieAuth provider appears to only support POST on its token endpoint, not implementing the full IndieAuth specification which requires both:
- POST for token issuance (Section 6.3)
- GET for token verification (Section 6.3.4)
## Consequences
### Immediate Impact
- StarPunk cannot verify tokens with gondulf.thesatelliteoflove.com
- The provider needs to be fixed to support GET requests for verification
- Our code is correct and should NOT be changed
### Potential Solutions
1. **Provider Fix** (Recommended): The gondulf IndieAuth provider should implement GET support for token verification per spec
2. **Provider Switch**: Use a compliant IndieAuth provider that fully implements the specification
3. **Non-Compliant Mode** (Not Recommended): Add a workaround to use POST for verification with non-compliant providers
## Alternatives Considered
### Alternative 1: Use POST for Verification
- **Rejected**: Violates IndieAuth specification
- Would make StarPunk non-compliant
- Would create confusion about proper IndieAuth implementation
### Alternative 2: Support Both GET and POST
- **Rejected**: Adds complexity without benefit
- The spec is clear: GET is required
- Supporting non-standard behavior encourages poor implementations
### Alternative 3: Document Provider Requirements
- **Accepted as Additional Action**: We should clearly document that StarPunk requires IndieAuth providers that fully implement the W3C specification
## Technical Details
### Correct Token Verification Flow
```
Client → GET /token
Authorization: Bearer {token}
Server → 200 OK
{
"me": "https://user.example.net/",
"client_id": "https://app.example.com/",
"scope": "create update"
}
```
### What Gondulf Is Doing Wrong
```
Client → GET /token
Authorization: Bearer {token}
Server → 405 Method Not Allowed
(Server only accepts POST)
```
## References
- [W3C IndieAuth Specification - Token Verification](https://www.w3.org/TR/indieauth/#token-verification)
- [W3C IndieAuth Specification - Token Endpoint](https://www.w3.org/TR/indieauth/#token-endpoint)
- StarPunk Implementation: `/home/phil/Projects/starpunk/starpunk/auth_external.py`
## Recommendation
1. Contact the gondulf IndieAuth provider maintainer and inform them their implementation is non-compliant
2. Provide them with the W3C spec reference showing GET is required for verification
3. Do NOT modify StarPunk's code - it is correct
4. Consider adding a note in our documentation about provider compliance requirements

View File

@@ -0,0 +1,144 @@
# ADR-039: Micropub URL Construction Fix
## Status
Accepted
## Context
After the v1.0.0 release, a bug was discovered in the Micropub implementation where the Location header returned after creating a post contains a double slash:
- **Expected**: `https://starpunk.thesatelliteoflove.com/notes/so-starpunk-v100-is-complete`
- **Actual**: `https://starpunk.thesatelliteoflove.com//notes/so-starpunk-v100-is-complete`
### Root Cause Analysis
The issue occurs due to a mismatch between how SITE_URL is stored and used:
1. **Configuration Storage** (`starpunk/config.py`):
- SITE_URL is normalized to always end with a trailing slash (lines 26, 92)
- This is required for IndieAuth/OAuth specs where root URLs must have trailing slashes
- Example: `https://starpunk.thesatelliteoflove.com/`
2. **URL Construction** (`starpunk/micropub.py`):
- Constructs URLs using: `f"{site_url}/notes/{note.slug}"` (lines 311, 381)
- This adds a leading slash to the path segment
- Results in: `https://starpunk.thesatelliteoflove.com/` + `/notes/...` = double slash
3. **Inconsistent Handling**:
- RSS feed module (`starpunk/feed.py`) correctly strips trailing slash before use (line 77)
- Micropub module doesn't handle this, causing the bug
## Decision
Fix the URL construction in the Micropub module by removing the leading slash from the path segment. This maintains the trailing slash convention in SITE_URL while ensuring correct URL construction.
### Implementation Approach
Change the URL construction pattern from:
```python
permalink = f"{site_url}/notes/{note.slug}"
```
To:
```python
permalink = f"{site_url}notes/{note.slug}"
```
This works because SITE_URL is guaranteed to have a trailing slash.
### Affected Code Locations
1. `starpunk/micropub.py` line 311 - Location header in `handle_create`
2. `starpunk/micropub.py` line 381 - URL in Microformats2 response in `handle_query`
## Rationale
### Why Not Strip the Trailing Slash?
We could follow the RSS feed approach and strip the trailing slash:
```python
site_url = site_url.rstrip("/")
permalink = f"{site_url}/notes/{note.slug}"
```
However, this approach has downsides:
- Adds unnecessary processing to every request
- Creates inconsistency with how SITE_URL is used elsewhere
- The trailing slash is intentionally added for IndieAuth compliance
### Why This Solution?
- **Minimal change**: Only modifies the string literal, not the logic
- **Consistent**: SITE_URL remains normalized with trailing slash throughout
- **Efficient**: No runtime string manipulation needed
- **Clear intent**: The code explicitly shows we expect SITE_URL to end with `/`
## Consequences
### Positive
- Fixes the immediate bug with minimal code changes
- No configuration changes required
- No database migrations needed
- Backward compatible - doesn't break existing data
- Fast to implement and test
### Negative
- Developers must remember that SITE_URL has a trailing slash
- Could be confusing without documentation
- Potential for similar bugs if pattern isn't followed elsewhere
### Mitigation
- Add a comment at each URL construction site explaining the trailing slash convention
- Consider adding a utility function in future versions for URL construction
- Document the SITE_URL trailing slash convention clearly
## Alternatives Considered
### 1. Strip Trailing Slash at Usage Site
```python
site_url = current_app.config.get("SITE_URL", "http://localhost:5000").rstrip("/")
permalink = f"{site_url}/notes/{note.slug}"
```
- **Pros**: More explicit, follows RSS feed pattern
- **Cons**: Extra processing, inconsistent with config intention
### 2. Remove Trailing Slash from Configuration
Modify `config.py` to not add trailing slashes to SITE_URL.
- **Pros**: Simpler URL construction
- **Cons**: Breaks IndieAuth spec compliance, requires migration for existing deployments
### 3. Create URL Builder Utility
```python
def build_url(base, *segments):
"""Build URL from base and path segments"""
return "/".join([base.rstrip("/")] + list(segments))
```
- **Pros**: Centralized URL construction, prevents future bugs
- **Cons**: Over-engineering for a simple fix, adds unnecessary abstraction for v1.0.1
### 4. Use urllib.parse.urljoin
```python
from urllib.parse import urljoin
permalink = urljoin(site_url, f"notes/{note.slug}")
```
- **Pros**: Standard library solution, handles edge cases
- **Cons**: Adds import, slightly less readable, overkill for this use case
## Implementation Notes
### Version Impact
- Current version: v1.0.0
- Fix version: v1.0.1 (PATCH increment - backward-compatible bug fix)
### Testing Requirements
1. Verify Location header has single slash
2. Test with various SITE_URL configurations (with/without trailing slash)
3. Ensure RSS feed still works correctly
4. Check all other URL constructions in the codebase
### Release Type
This qualifies as a **hotfix** because:
- It fixes a bug in production (v1.0.0)
- The fix is isolated and low-risk
- No new features or breaking changes
- Critical for proper Micropub client operation
## References
- [Issue Report]: Malformed redirect URL in Micropub implementation
- [W3C Micropub Spec](https://www.w3.org/TR/micropub/): Location header requirements
- [IndieAuth Spec](https://indieauth.spec.indieweb.org/): Client ID URL requirements
- ADR-028: Micropub Implementation Strategy
- docs/standards/versioning-strategy.md: Version increment guidelines