Reader Architecture
Recommended internal architecture for an om-aware RSS reader: parser, credential store, unlock pipeline, checkout flow, portability import/export.
Reader Reference Architecture, Miniflux Fork
The reader side of the first interop test. Forks Miniflux to add om support. The goal is proving that a normal RSS reader can subscribe to a paid om feed, drive the checkout flow, and render gated content correctly.
Why Miniflux and not NetNewsWire
NetNewsWire would be the higher-impact target (large active iOS+macOS user base, open-source, Apple platform reach). Miniflux is the better v0 choice because:
- Self-hosted web UI. The checkout flow happens in a browser. No per-platform native integration required.
- Go codebase, ~40k lines. Small and approachable for one engineer.
- Active single-maintainer governance. Frédéric Guillot has a track record of accepting well-scoped PRs.
- Existing multi-account semantics. Each Miniflux user already has their own feed subscriptions; adding per-feed authentication tokens fits the mental model.
- Upstream-merge possibility. If the PR is accepted into Miniflux proper (rather than remaining a fork), the adoption story improves dramatically.
NetNewsWire gets a parallel effort in Phase 4 (months 10-12) using lessons learned from Miniflux.
Scope
The Miniflux fork must support:
- Level 1 (Parsing): read
omnamespace declarations, display<om:tier>info, show<om:preview>content for gated items - Level 2 (URL token): accept a pre-obtained tokenized feed URL, fetch content with it normally
- Level 5 (Checkout): present an “Upgrade to read” button when user hits a gated item, launch the publisher’s
/api/om/checkoutflow in the system browser, poll/api/om/entitlementsfor completion, refresh the feed after entitlement is granted
Levels 3, 4, 6, 7, 8 are out of scope for v0.
Architecture
Miniflux has a clean separation between the feed poller (a goroutine that periodically fetches feeds), the storage layer (PostgreSQL), and the web UI (server-rendered HTML with minimal JS). The om additions fit cleanly into each.
┌─────────────────────────────────────────────────────────────┐
│ Miniflux instance │
│ │
│ ┌──────────────┐ │
│ │ Feed │ │
│ │ Poller │◀── periodic: fetch feed URL │
│ │ │ (may include auth token in URL or │
│ └──────┬───────┘ Authorization header) │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ om parser │───▶│ PostgreSQL │ │
│ │ │ │ │ │
│ │ - reads │ │ + om_ │ │
│ │ discovery │ │ feed_auth │ │
│ │ doc │ │ table │ │
│ │ - persists │ │ + om_ │ │
│ │ offers │ │ offers │ │
│ │ │ │ table │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Web UI │ │
│ │ │ │
│ │ + Subscribe │ │
│ │ button │ │
│ │ + Manage │ │
│ │ subscription│ │
│ └──────┬───────┘ │
│ │ │
└─────────────────────────────┼───────────────────────────────┘
│
▼ (in browser)
┌─────────────────┐
│ Publisher's │
│ /api/om/ │
│ checkout │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Stripe │
│ Checkout │
└─────────────────┘
Components
1. internal/reader/om/, feed parsing
A new Go package that handles the om namespace. Integrates into Miniflux’s existing RSS parser as an extension; feeds without om are unaffected.
package om
type Feed struct {
Provider string
DiscoveryURL string
AuthMethods []string
Tiers []Tier
Offers []Offer
RevocationPolicy string
}
type Offer struct {
ID string
TierID string
Prices []Price
Checkouts []Checkout
}
func Parse(reader io.Reader) (*Feed, error) { /* ... */ }
2. internal/storage/om_auth.go, auth state
Per-feed auth token storage. Schema additions to Miniflux’s PostgreSQL:
CREATE TABLE om_feed_auth (
feed_id BIGINT PRIMARY KEY REFERENCES feeds(id),
provider TEXT NOT NULL,
auth_method TEXT NOT NULL,
url_token TEXT,
bearer_token TEXT,
bearer_expires TIMESTAMP,
refresh_token TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE om_offers (
id BIGSERIAL PRIMARY KEY,
feed_id BIGINT NOT NULL REFERENCES feeds(id),
offer_id TEXT NOT NULL,
tier_id TEXT,
data JSONB NOT NULL,
UNIQUE (feed_id, offer_id)
);
3. internal/reader/fetcher/om_auth.go, auth-aware fetch
When fetching a feed that has om_feed_auth state, the fetcher:
- Checks if
bearer_tokenis set and not expired → usesAuthorization: Bearer <token>header - Otherwise checks if
url_tokenis set → uses the token as URL parameter - Otherwise fetches unauthenticated (gets a preview-only feed)
4. internal/api/om/, checkout API
Miniflux exposes (to the browser only, not to external callers) a small API:
POST /om/subscribe, starts the checkout flow- Input:
feed_id,offer_id,psp - Action: POSTs to the publisher’s
/api/om/checkout, receives a checkout URL, returns it to the browser
- Input:
GET /om/status?feed_id=X, polls for entitlement- Polls the publisher’s
/api/om/entitlements?session_id=Yendpoint - On success, calls
/api/om/tokento receive a fresh JWT, stores it inom_feed_auth - Returns the user’s current subscription status
- Polls the publisher’s
5. UI changes
Feed view:
- Items with
<om:access>previewshow the preview content, then a “Read full article” button - Clicking the button opens a modal showing available offers from the feed’s
om:offerlist - User picks an offer, clicks “Subscribe via Stripe”, and is redirected to the checkout URL in a new tab
Subscription management:
- New view at
/om/subscriptionslisting all feeds where the user has activeomauth - Shows: feed name, tier, renewal date, “Manage on publisher site” link
Initial discovery:
- When a user subscribes to a new feed URL, the parser checks for
ommarkup. If found, Miniflux shows a banner: “This feed offers paid subscriptions. Some content requires a subscription.”
The tricky parts
Cross-browser-tab flow. The user clicks “Subscribe” in Miniflux, goes to Stripe in a new tab, completes payment. Miniflux needs to know they succeeded. Two approaches:
- Return URL with polling. Miniflux shows “Waiting for confirmation…” and polls
/om/statusevery 3 seconds. The user completes Stripe, Stripe redirects back to the publisher’s URL (which may just show a confirmation page), Miniflux’s poll picks up the success. - Manual “I’ve completed payment” button. Uglier UX but works in all browsers. Button triggers an immediate status check.
v0 uses polling with a manual fallback if polling times out.
Token refresh. Bearer tokens from the publisher expire. The fetcher needs to detect 401 responses, call /api/om/token with the refresh token, store the new bearer token, retry the feed fetch. This is standard OAuth client plumbing; Miniflux’s existing HTTP client abstractions make it reasonably clean.
Storing tokens securely. Miniflux stores feed URLs in plaintext today; stored bearer tokens should be encrypted at rest. The fork adds a small encryption layer using the Miniflux admin-configured secret key. Not bulletproof against a compromised Miniflux server, but substantially better than plaintext.
Per-user vs per-instance auth. Miniflux is multi-user but feeds are shared across users (two users subscribing to the same feed URL result in one feeds row). For om, different users will have different entitlements on the same feed. The fix: om_feed_auth is keyed by (feed_id, user_id) not just feed_id. Slight storage schema change from §2 above.
Scope discipline
What the fork does NOT do in v0:
- No multiple-PSP support. Stripe only. Adding Mollie to the reader side happens in Phase 2 along with the publisher-side Mollie addition.
- No VC verification. Bearer tokens only. OM-VC comes in Phase 3.
- No group subscription UI. A user in a company group plan can still subscribe, but the UI treats it as an individual subscription with group-scoped entitlements. Fine for v0.
- No bundle discovery. If a feed declares
<om:bundled-from>, v0 ignores it. Bundle support lands in Phase 5.
Testing
v0 ships with:
- Unit tests for the parser, storage layer, auth logic
- Integration tests against a local
om-ghostinstance running in Docker - Interop test:
om-test-suitecompliance for the Miniflux fork claiming Level 2 + Level 5 support - At least one public-facing test feed (hosted by the working group) that exercises every
omfeature a v0 reader should handle
Upstream strategy
The fork is called miniflux-om. When the implementation stabilises, a pull request is opened against Miniflux main describing the scope: roughly 2,000 lines, one optional database table, an opt-in UI surface, opt-in per feed.
If the PR is accepted, miniflux-om sunsets and users converge on Miniflux main. If it is rejected on grounds of scope or maintenance burden, miniflux-om continues as a permanent fork rebased against upstream quarterly and serves as the canonical reference reader. Either outcome is acceptable; the specification requires a reader that exists and works, not a specific one.
File layout
miniflux-om/
├── (existing miniflux files)
├── internal/
│ ├── om/
│ │ ├── parser.go
│ │ ├── discovery.go
│ │ └── types.go
│ ├── storage/
│ │ └── om_auth.go
│ ├── reader/fetcher/
│ │ └── om_auth.go
│ └── api/
│ └── om/
│ ├── subscribe.go
│ ├── status.go
│ └── token.go
├── template/
│ ├── views/
│ │ └── om_subscriptions.html
│ └── partials/
│ └── om_upgrade_modal.html
└── migrations/
└── 002_add_om_tables.sql
Licensing
Apache 2.0 matching Miniflux upstream.