feat(research): add a protected authoring interface for Research articles - #28
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_EMAILauthorization.src/lib/research/authoring-service.ts): sits between authorization and the repository. Every operation re-checksgetAuthorizationResult()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.ResearchAuthoringRepositorynow identifies articles byidinstead ofslug, and gainedlistArticles()/getArticleById().DraftInputgained an optionalslugoverride, validated for uniqueness (SlugTakenError) and locked server-side once published.reading_minutesis now derived from content rather than author-supplied.react-markdown+remark-gfm+mdx-componentspipeline 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.Validation
npm run test— 56/56 pass (19 new tests)npm run lint— cleannpm run build— cleannext devHMR cycles — zeropoisoned stuboccurrencesopennextjs-cloudflare previewagainst realworkerd— write routes correctly redirect when unauthenticatedRemaining 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