Skip to content

feat(research): add a protected authoring interface for Research articles - #28

Merged
psatomas merged 1 commit into
mainfrom
feature/research-authoring
Sep 2, 2026
Merged

feat(research): add a protected authoring interface for Research articles#28
psatomas merged 1 commit into
mainfrom
feature/research-authoring

Conversation

@psatomas

@psatomas psatomas commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Adds /research/write, /research/write/new, and /research/write/[id] — a private MDX/Markdown editor for creating, editing, publishing, and deleting Research articles, gated end-to-end by the existing Google OAuth + RESEARCH_AUTHOR_EMAIL authorization.

  • Authoring boundary (src/lib/research/authoring-service.ts): sits between authorization and the repository. Every operation re-checks getAuthorizationResult() itself and returns a typed { ok, reason, message } result rather than throwing. The repository layer stays entirely auth-agnostic; the public read side never imports the authoring service, so it can't accidentally start requiring a session.
  • ResearchAuthoringRepository now identifies articles by id instead of slug, and gained listArticles()/getArticleById(). DraftInput gained an optional slug override, validated for uniqueness (SlugTakenError) and locked server-side once published.
  • reading_minutes is now derived from content rather than author-supplied.
  • The editor's preview pane reuses the exact same react-markdown + remark-gfm + mdx-components pipeline the public article page renders through — no second Markdown renderer.
  • getResearchAuthoringRepository() follows the same Cloudflare binding lifecycle rule as the public repository and Oracle's KV cache — resolved fresh per call, never cached at module scope.
  • No migration required — the existing D1 schema already covered everything this needed.

Validation

  • npm run test — 56/56 pass (19 new tests)
  • npm run lint — clean
  • npm run build — clean
  • A temporary, unauthenticated diagnostic route exercised the real local D1 binding directly (create/list/duplicate-slug-rejection/rename/publish/unpublish/delete) — all 12 steps passed, route removed before this commit
  • Repeated next dev HMR cycles — zero poisoned stub occurrences
  • opennextjs-cloudflare preview against real workerd — write routes correctly redirect when unauthenticated

Remaining limitation

The interactive editor UI itself wasn't clicked through in a real browser (no way to complete a Google sign-in from this environment). Everything underneath it — authorization gating, CRUD, slug uniqueness, publish/unpublish visibility — was verified directly. Manual follow-up: sign in, visit /research/write, create → publish → verify on /research → unpublish → delete.

🤖 Generated with Claude Code

…cles

Adds /research/write, /research/write/new, and /research/write/[id] — a
private MDX/Markdown editor for creating, editing, publishing, and
deleting Research articles, gated end-to-end by the existing Google
OAuth + RESEARCH_AUTHOR_EMAIL authorization.

Authoring boundary (src/lib/research/authoring-service.ts) sits between
authorization and the repository: every operation re-checks
getAuthorizationResult() itself and returns a typed
{ ok, reason, message } result rather than throwing, so a Server Action
or page never has to trust that a caller already checked. The repository
layer (repository.ts/d1-repository.ts) stays entirely auth-agnostic —
nothing there imports from src/lib/auth — and the public read side
(getResearchRepository, /research, /research/[slug]) never imports the
authoring service at all, so it can't accidentally start requiring a
session.

ResearchAuthoringRepository now identifies articles by `id` rather than
`slug` (a draft's slug can change before publish; `id` is stable across
that), and gained listArticles()/getArticleById() for the editor's list
and edit views. DraftInput gained an optional `slug` override — resolved
through the same slugify() the editor uses for its live preview,
validated for uniqueness before every write (SlugTakenError on
collision), and locked server-side once an article is published (not
just disabled in the UI) so a live public URL can't be silently changed.
reading_minutes is now derived from content rather than author-supplied.

The editor's preview pane reuses the exact same react-markdown +
remark-gfm + mdx-components pipeline the public article page renders
through — no second Markdown renderer.

getResearchAuthoringRepository() (src/lib/research/index.ts) follows the
same Cloudflare binding lifecycle rule established for the public
repository and Oracle's KV cache: resolved fresh from
getCloudflareContext() on every call, never cached at module scope.

No migration required — the existing D1 schema (status, published_at,
slug UNIQUE) already covered every operation this needed.

Validated with a temporary, unauthenticated diagnostic route exercising
the real local D1 binding directly (create/list/duplicate-slug-rejection/
rename/publish/unpublish/delete, all 12 steps passing), removed before
this commit. Also validated: repeated next dev HMR cycles with zero
poisoned-stub occurrences, and opennextjs-cloudflare preview against real
workerd confirming the write routes correctly redirect when
unauthenticated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@psatomas
psatomas merged commit 962a7eb into main Sep 2, 2026
1 check passed
@psatomas
psatomas deleted the feature/research-authoring branch September 2, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant