Skip to content

Diskless redirects: introduce storage abstraction (Phase 1)#27697

Draft
allouis wants to merge 8 commits intomainfrom
fabien/diskless-redirects-phase-1
Draft

Diskless redirects: introduce storage abstraction (Phase 1)#27697
allouis wants to merge 8 commits intomainfrom
fabien/diskless-redirects-phase-1

Conversation

@allouis
Copy link
Copy Markdown
Collaborator

@allouis allouis commented May 6, 2026

Summary

Phase 1 of the Diskless Redirects Config project. Replaces the legacy CustomRedirectsAPI with a Repository-pattern split — RedirectsService orchestrating a RedirectsStore (FileStore for now, GCSStore in Phase 2). No infrastructure change, only a refactor and one user-visible flip.

The store boundary is the seam Phase 2 plugs into. activate() is independently callable so the future cross-instance notifier can rebuild in-memory state without re-persisting.

User-visible change

The admin redirects download endpoint now always returns YAML (redirects.yaml) regardless of the original upload format. Previously JSON uploads downloaded as JSON, YAML uploads as YAML — making the download → edit → re-upload loop awkward when the format varied. YAML is the recommended format per the docs and the only one the existing tooling generates examples for.

Self-hosters who SSH-edit redirects.yaml directly on disk are unaffected. Self-hosters who use the admin upload endpoint will see their YAML comments / anchors / formatting stripped after the first upload (FileStore writes JSON regardless of input format). The replaced YAML is preserved as a timestamped backup in content/data/.

Stack (best reviewed commit-by-commit)

  1. `Improved tests for CustomRedirectsAPI before refactor` — characterization tests, locks current behaviour
  2. `Added store contract tests and RedirectConfig typedef` (HKG-1692) — the seam
  3. `Extracted redirect-config parsing and added YAML serialisation` (HKG-1693) — pure-function parser, hardened against list-shaped uploads, multi-line values, special chars
  4. `Implemented FileStore for redirects` (HKG-1694) — repository pattern, atomic writes, timestamped backups
  5. `Added RedirectsService with standalone activate()` (HKG-1695) — thin orchestrator, per-item skip-and-log on bad rules
  6. `Wired RedirectsService and FileStore into the redirects module` (HKG-1696) — module boundary swap, exports unchanged
  7. `🎨 Redirects download now returns YAML` (HKG-1697) — the user-visible flip
  8. `Removed legacy CustomRedirectsAPI` (HKG-1698) — dead code sweep

Test plan

  • `pnpm test:single test/unit/server/services/custom-redirects/` (55 tests)
  • `pnpm test:single test/e2e-api/admin/redirects.test.js` (download + upload)
  • `pnpm test:single test/legacy/api/admin/redirects.test.js`
  • `pnpm lint` clean (only pre-existing unrelated warnings)
  • CodeRabbit review on this PR
  • Manual upload + download verification on a test instance with a YAML config

🤖 Generated with Claude Code

ref https://linear.app/ghost/project/diskless-redirects-config-c4c5916f50a8

Adds characterization tests covering observable behaviour of the redirects
service before extracting it into a Repository-pattern store + service. Locks
in:

- init() loads parsed redirects into the redirect manager when a file exists
  (existing tests only covered the no-file case)
- init() logs and swallows errors when the redirects file is unparseable
  (graceful degradation that boot.js relies on)
- setFromFilePath() does not attempt a backup when no existing file is on
  disk (the conditional backup branch was previously untested)
- setFromFilePath() runs validation before any write or activation, and a
  validation failure short-circuits with no filesystem or manager mutation

These tests will be migrated/adapted as part of the upcoming
RedirectsService + FileStore split, but they're added here in a separate
commit so the refactor is provably behaviour-preserving.
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 42068fad-871f-48cb-80e2-6ea315c4b6ee

📥 Commits

Reviewing files that changed from the base of the PR and between c173d01 and 2ed05af.

📒 Files selected for processing (12)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
 ________________________________________________________________
< CodeHamster is my sidekick. She powers the GPU with her wheel. >
 ----------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Custom redirects were refactored from a file-path-based CustomRedirectsAPI to a RedirectsService backed by a new FileStore. FileStore reads legacy redirects.yaml or redirects.json, writes atomically to JSON, and creates timestamped backups. A redirect-config-parser module was added to parse JSON/YAML and serialize YAML for downloads. The admin redirects API now serves a redirects.yaml download and accepts YAML/JSON uploads. Validation was strengthened and the old CustomRedirectsAPI and its unit test were removed; multiple new unit and integration tests were added or updated.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing a storage abstraction layer as Phase 1 of the Diskless Redirects project by replacing CustomRedirectsAPI with RedirectsService and FileStore.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the refactoring from CustomRedirectsAPI to a repository pattern, the user-visible YAML download change, and providing a detailed stack of commits.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fabien/diskless-redirects-phase-1

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/services/custom-redirects/file-store.js`:
- Around line 50-57: replaceAll currently calls _backup() before the new file is
durably written, which can remove the live redirects if the subsequent
_writeAtomic/rename fails; change the flow in replaceAll (and the same pattern
at the other block) to first write the new JSON to a temporary/atomic path using
_writeAtomic (ensuring the write+rename to the canonical targetPath succeeds)
while leaving the existing file in place, then once the new file has been
committed/successfully renamed into place, call _backup(existingPath) to move
the previous canonical file to its backup location; reference replaceAll,
_findExistingFile, _writeAtomic and _backup to implement: write new -> commit ->
only then backup old.

In `@ghost/core/core/server/services/custom-redirects/redirect-config-parser.js`:
- Around line 49-54: Currently the parser accepts any plain mapping
(isPlainMapping(configYaml)) including mappings with no recognized status
sections, which later results in silently returning [] and wiping redirects;
update the validation after the isPlainMapping check to assert that the
top-level mapping contains at least one of the recognized status keys '301' or
'302' and throw the same errors.BadRequestError (using tpl(messages.yamlInvalid)
and tpl(messages.redirectsHelp)) if neither key is present so malformed YAML
like "foo: bar" is rejected; look for isPlainMapping, errors.BadRequestError,
tpl(messages.yamlInvalid) and messages.redirectsHelp in
redirect-config-parser.js to implement this guard.

In `@ghost/core/core/server/services/custom-redirects/redirects-service.js`:
- Around line 78-81: The replace() flow can persist redirects via
store.replaceAll() then call _loadIntoManager(), which swallows per-item
failures (via addRedirect()), leading to a persisted-but-not-activated state;
fix by making activation strict: before calling this.store.replaceAll(redirects)
or persisting, attempt to load all entries into a throwaway manager (e.g.,
instantiate the same RedirectsManager used by _loadIntoManager() or call
addRedirect() against a temp instance) and fail immediately on the first
rejected addRedirect() so replace() throws; only if the throwaway load succeeds,
proceed to this.store.replaceAll(redirects) and then load into the real manager
(or, alternatively, change _loadIntoManager() to throw on the first
addRedirect() error and have replace() propagate that error). Ensure the change
affects replace() (and the same pattern used in the similar block around lines
88-99) so persistence is all-or-nothing.

In `@ghost/core/core/server/services/custom-redirects/validation.js`:
- Around line 12-16: The validator currently only checks truthiness but needs to
enforce non-empty strings for `from` and `to` as promised by the JSDoc; update
the validation in the function (e.g., validateRedirects / the batch validator in
this file) to assert typeof redirect.from === 'string' &&
redirect.from.trim().length > 0 and typeof redirect.to === 'string' &&
redirect.to.trim().length > 0 before attempting to compile the `from` RegExp,
and throw the existing error with a clear message when these type checks fail;
ensure the RegExp compilation only runs on a confirmed string `from`.

In `@ghost/core/test/unit/server/services/custom-redirects/file-store.test.js`:
- Around line 151-166: The test is nondeterministic because it relies on
wall-clock seconds to collide backup filenames; modify the test to freeze the
clock so both replaceAll calls generate the same timestamped backup name. Use
the test suite's fake-timer utility (e.g., sinon.useFakeTimers) to set and
freeze time before creating the FileStore and performing the fs.writeFile/
store.replaceAll calls, then restore the clock at the end; this guarantees the
collision path in FileStore.replaceAll is exercised deterministically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d66456f7-8c2a-4606-8ba3-665ccc819e68

📥 Commits

Reviewing files that changed from the base of the PR and between db7b3f8 and 1ce5558.

📒 Files selected for processing (17)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/core/server/services/custom-redirects/types.js
  • ghost/core/core/server/services/custom-redirects/validation.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/in-memory-store.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js
  • ghost/core/test/unit/server/services/custom-redirects/in-memory-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
💤 Files with no reviewable changes (2)
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js

Comment thread ghost/core/core/server/services/custom-redirects/file-store.js
Comment thread ghost/core/core/server/services/custom-redirects/validation.js
@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from 1ce5558 to 2c48126 Compare May 6, 2026 09:29
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Addressed CodeRabbit's 5 findings. Force-pushed.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@allouis: Thanks for addressing the findings and letting me know! I'll kick off a fresh review of the updated changes now.

[review]

@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

@coderabbitai — for visibility, here's where each of the round-1 findings was addressed in the force-pushed stack (head 2c48126):

Finding Resolved in
file-store.js replaceAll() ordering — backup before durable write xkquqozr (Implemented FileStore for redirects). Same-path case copies to backup before atomic rename; different-path case writes new canonical first, then moves old to backup. New test preserves the previous redirects on disk when the atomic write fails simulates a write failure and asserts the canonical path still serves the original redirects.
redirect-config-parser.js — accepts mappings with no 301/302 sections otqpxlsl (Extracted redirect-config parsing). Added explicit Object.hasOwn(configYaml, '301') || Object.hasOwn(configYaml, '302') guard. New test rejects foo: bar with BadRequestError.
redirects-service.js replace() — persisted-but-partially-active state xoluwmkk (Added RedirectsService with standalone activate). Added _verifyAllLoadable() pre-flight that loads every entry into a throwaway DynamicRedirectManager and throws ValidationError on the first rejection, before store.replaceAll() runs. Factory injectable for tests. New test asserts no persist + no live-manager call when the dry run rejects.
validation.js — accepts non-string from / to upxwxozl (Added store contract tests). Replaced truthy check with typeof === 'string' && trim().length > 0. New tests cover object from, object to, and whitespace-only from.
file-store.test.js — non-deterministic backup-collision test xkquqozr. Test now injects a fixed getBackupFilePath via the constructor so both writes resolve to the same backup target deterministically, independent of wall-clock seconds.

Please verify when convenient.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== file-store.js ==="
fd -e js file-store.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== redirect-config-parser.js ==="
fd -e js redirect-config-parser.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== redirects-service.js ==="
fd -e js redirects-service.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== validation.js ==="
fd -e js validation.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== file-store.test.js ==="
fd -e js file-store.test.js --search-path ghost/core/test | xargs cat

Length of output: 27927


@allouis: All five round-1 findings verified against the current source. Here's the breakdown:

Finding Status Evidence
file-store.js backup ordering ✅ Confirmed Same-path: fs.copy to backup runs before _writeAtomic; different-path: _writeAtomic runs before _backup. If _writeAtomic throws, the canonical path is untouched in both branches. The new test stubs _writeAtomic to reject and asserts getAll() still returns the original data.
redirect-config-parser.js no-301/302 guard ✅ Confirmed Object.hasOwn(configYaml, '301') || Object.hasOwn(configYaml, '302') guard is in place immediately after the isPlainMapping check — foo: bar throws BadRequestError rather than parsing to [].
redirects-service.js pre-persist dry-run ✅ Confirmed _verifyAllLoadable() creates a throwaway DynamicRedirectManager via injectable createDryRunManager, loads every entry, and throws ValidationError (with redirect context) if addRedirect returns null — all before store.replaceAll() is called.
validation.js non-string from/to ✅ Confirmed isNonEmptyString = value => typeof value === 'string' && value.trim().length > 0 helper is used for both redirect.from and redirect.to, correctly rejecting objects, null, undefined, and whitespace-only strings.
file-store.test.js deterministic collision test ✅ Confirmed getBackupFilePath: () => fixedBackup injected at construction; both replaceAll calls target the same fixed path regardless of wall-clock time.

The implementation looks solid. The only minor observation worth noting: in _loadIntoManager, when redirectManager.addRedirect returns null (the manager silently rejects), that branch is currently not explicitly caught — the log-and-skip path relies on validate throwing first. But that case is now pre-empted at replace() time by _verifyAllLoadable, so the live path is safe. At boot via activate()_loadIntoManager, the null-return case would be silently dropped (no log). You may want to add an explicit null-check there too, but that's a minor nit rather than a blocking issue.

@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from 2c48126 to 469b70c Compare May 6, 2026 10:21
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Addressed the round-2 nit on _loadIntoManager null-return logging. Force-pushed to head 469b70c.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

🧩 Analysis chain

Let me verify the fix before triggering the review.


🏁 Script executed:

#!/bin/bash
echo "=== redirects-service.js ==="
fd -e js redirects-service.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

Length of output: 8141


@allouis: The round-2 nit is confirmed fixed. In _loadIntoManager, after this.redirectManager.addRedirect(...) returns, there is now an explicit null-check:

if (id === null) {
    throw new errors.ValidationError({
        message: tpl(messages.rejectedRedirect, { from: redirect.from, to: redirect.to }),
        context: redirect
    });
}

The ValidationError is caught by the surrounding try/catch and emitted via logging.error(...), so a regex that survives validate() but fails the live manager's buildRegex at boot time will now surface as an operator-visible error rather than silently disappearing. The JSDoc comment above the check also clearly explains the rationale. All three rounds of findings are now accounted for. Triggering a fresh review of the force-pushed stack.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ghost/core/core/server/services/custom-redirects/validation.js (1)

26-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard redirect entry shape before property access.

At Line 27, malformed entries like null can throw a raw TypeError (redirect.from) instead of the intended ValidationError. Add a null/object guard first so bad payloads consistently return the redirects-format error.

Proposed fix
-    _.each(redirects, function (redirect) {
-        if (!isNonEmptyString(redirect.from) || !isNonEmptyString(redirect.to)) {
+    _.each(redirects, function (redirect) {
+        if (!redirect || typeof redirect !== 'object' || !isNonEmptyString(redirect.from) || !isNonEmptyString(redirect.to)) {
             throw new errors.ValidationError({
                 message: tpl(messages.redirectsWrongFormat),
                 context: redirect,
                 help: tpl(messages.redirectsHelp)
             });
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/custom-redirects/validation.js` around lines
26 - 33, The loop over redirects accesses redirect.from/redirect.to without
ensuring redirect is an object; update the _.each callback to first guard that
redirect is a non-null object (e.g. redirect && typeof redirect === 'object')
before calling isNonEmptyString, and if that guard fails treat it as invalid and
throw the same errors.ValidationError using tpl(messages.redirectsWrongFormat)
and tpl(messages.redirectsHelp); this touches the _.each(redirects, function
(redirect) { ... }) block and uses isNonEmptyString, errors.ValidationError, tpl
and messages.redirectsWrongFormat/messages.redirectsHelp.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/api/endpoints/redirects.js`:
- Around line 37-41: The query(frame) logic only detects '.yaml' exactly,
causing '.yml' and mixed-case extensions to be misrouted; change the check to
normalize the extension (e.g., const ext = (frame.file.ext || '').toLowerCase())
and call parseYaml when ext === '.yaml' || ext === '.yml', otherwise call
parseJson; update the conditional around frame.file.ext used in query to use
this normalized ext so '.YML' and '.YAML' are handled correctly by parseYaml.

In `@ghost/core/core/server/services/custom-redirects/redirects-service.js`:
- Around line 108-129: In _verifyAllLoadable(), wrap calls to the dry-run
manager's addRedirect (created via createDryRunManager()) in a try/catch so any
exception thrown by DynamicRedirectManager.addRedirect is caught and rethrown as
an errors.ValidationError with the same tpl(messages.rejectedRedirect, {from:
redirect.from, to: redirect.to}) message, include context: redirect and help:
tpl(messages.redirectsHelp); keep the existing null-check behavior but unify
thrown errors into the same ValidationError shape so replace() surfaces a
user-facing validation failure instead of a raw 500.

---

Outside diff comments:
In `@ghost/core/core/server/services/custom-redirects/validation.js`:
- Around line 26-33: The loop over redirects accesses redirect.from/redirect.to
without ensuring redirect is an object; update the _.each callback to first
guard that redirect is a non-null object (e.g. redirect && typeof redirect ===
'object') before calling isNonEmptyString, and if that guard fails treat it as
invalid and throw the same errors.ValidationError using
tpl(messages.redirectsWrongFormat) and tpl(messages.redirectsHelp); this touches
the _.each(redirects, function (redirect) { ... }) block and uses
isNonEmptyString, errors.ValidationError, tpl and
messages.redirectsWrongFormat/messages.redirectsHelp.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4d730685-c4fa-4d1a-b47b-f0a62a108d3d

📥 Commits

Reviewing files that changed from the base of the PR and between 1ce5558 and 469b70c.

📒 Files selected for processing (18)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/core/server/services/custom-redirects/types.js
  • ghost/core/core/server/services/custom-redirects/validation.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/in-memory-store.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js
  • ghost/core/test/unit/server/services/custom-redirects/in-memory-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
  • ghost/core/test/unit/server/services/custom-redirects/validation.test.js
💤 Files with no reviewable changes (2)
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js

Comment thread ghost/core/core/server/api/endpoints/redirects.js Outdated
@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from 469b70c to 1513dbd Compare May 6, 2026 10:34
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Round-3 fixes pushed (head 1513dbd):

  • validation.js — guard entry shape (null / non-object) before property access; raise ValidationError instead of TypeError. Tests added for null + primitive entries.
  • redirects-service.js — _verifyAllLoadable wraps the dry-run addRedirect in try/catch and rethrows as ValidationError, unifying the throw and null-return failure modes.
  • redirects.js endpoint — extracted parseUpload(content, ext) helper that case-insensitively normalises the extension and accepts .yml as an alias, keeping the upload query method within the project's max-api-complexity rule.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

🧩 Analysis chain

Let me verify the round-3 fixes before triggering the review.


🏁 Script executed:

#!/bin/bash
echo "=== validation.js ==="
fd -e js validation.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== redirects-service.js ==="
fd -e js redirects-service.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== redirects.js endpoint ==="
fd -e js redirects.js --search-path ghost/core/core/server/api/endpoints | xargs cat

echo ""
echo "=== validation.test.js (for new tests) ==="
fd -e js validation.test.js --search-path ghost/core/test | xargs cat

Length of output: 25847


@allouis: All three round-3 fixes verified against the current source. Here's the breakdown:

Fix Status Evidence
validation.js entry-shape guard ✅ Confirmed !redirect || typeof redirect !== 'object' prefixes the isNonEmptyString checks — null and primitive entries throw ValidationError rather than a raw TypeError. New tests cover both null entry and 'a string entry' cases.
redirects-service.js _verifyAllLoadable try/catch ✅ Confirmed dryRun.addRedirect(...) is wrapped in try/catch; any thrown error is re-thrown as ValidationError with the original err attached. The id === null check follows immediately after, unifying both failure modes into a consistent 4xx path.
redirects.js parseUpload helper ✅ Confirmed YAML_EXTENSIONS = new Set(['.yaml', '.yml']) handles the alias; rawExt is lowercased before the lookup, making it case-insensitive. The upload query method is now 3 lines, safely within the max-api-complexity rule.

All round-1, round-2, and round-3 findings are accounted for. Triggering a fresh review of the force-pushed stack.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js (1)

23-29: ⚡ Quick win

Add a contract check that getAll() returns a defensive copy.

The suite verifies input mutation safety after replaceAll, but not mutation safety of the array/objects returned by getAll(). Without this, a store can still leak internal state to callers.

Proposed test addition
 describe('getAll', function () {
     it('returns an empty array when no redirects have been stored', async function () {
         const result = await store.getAll();

         assert.deepEqual(result, []);
     });
+
+    it('returns data that callers cannot mutate in-place', async function () {
+        await store.replaceAll([{from: '/a', to: '/b', permanent: true}]);
+
+        const firstRead = await store.getAll();
+        firstRead.push({from: '/x', to: '/y', permanent: false});
+        firstRead[0].to = '/mutated';
+
+        assert.deepEqual(await store.getAll(), [
+            {from: '/a', to: '/b', permanent: true}
+        ]);
+    });
 });

Also applies to: 31-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js`
around lines 23 - 29, Add a contract test ensuring getAll returns a defensive
copy: call store.replaceAll with sample redirects, call store.getAll to obtain
the array, mutate the returned array (e.g. push/pop) and mutate an item object
inside it, then call store.getAll again and assert the store's internal data was
not affected; reference the store.getAll and store.replaceAll methods and ensure
both array-level and object-level mutations are tested so callers cannot mutate
internal state via the returned value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/services/custom-redirects/file-store.js`:
- Around line 74-75: The backup step (this._backup(existingPath)) can throw
after replaceAll() has already committed redirects.json (targetPath), leaving a
stale redirects.yaml and an unreachable new JSON; wrap the backup call in a
try/catch inside replaceAll() (or the containing function) and on any error
delete/unlink the newly written targetPath (redirects.json) to roll back the
committed JSON, then rethrow the original error so callers still see the
failure; reference existingPath, targetPath, this._backup(), and redirects.json
when locating where to add the try/catch and cleanup.

---

Nitpick comments:
In
`@ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js`:
- Around line 23-29: Add a contract test ensuring getAll returns a defensive
copy: call store.replaceAll with sample redirects, call store.getAll to obtain
the array, mutate the returned array (e.g. push/pop) and mutate an item object
inside it, then call store.getAll again and assert the store's internal data was
not affected; reference the store.getAll and store.replaceAll methods and ensure
both array-level and object-level mutations are tested so callers cannot mutate
internal state via the returned value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0e3923be-b42b-4a9a-b4dd-08abbf040919

📥 Commits

Reviewing files that changed from the base of the PR and between 469b70c and 1513dbd.

📒 Files selected for processing (18)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/core/server/services/custom-redirects/types.js
  • ghost/core/core/server/services/custom-redirects/validation.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/in-memory-store.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js
  • ghost/core/test/unit/server/services/custom-redirects/in-memory-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
  • ghost/core/test/unit/server/services/custom-redirects/validation.test.js
💤 Files with no reviewable changes (2)
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/in-memory-store.test.js

Comment thread ghost/core/core/server/services/custom-redirects/file-store.js Outdated
ref https://linear.app/ghost/issue/HKG-1692

Establishes the seam where storage backends become swappable. Without a
shared contract, FileStore (existing local-disk behaviour) and the
upcoming GCSStore would inevitably drift on edge cases like empty state,
overwrite semantics, and reference handling.

- types.js: canonical JSDoc typedefs for RedirectConfig and RedirectsStore.
  RedirectConfig fields (from, to, permanent) verified against current
  validation.js and DynamicRedirectManager.addRedirect call sites — the
  store handles only those, no extras.
- helpers/store-contract.js: a parameterised describe block any store
  implementation can plug into via a createStore factory.
- helpers/in-memory-store.js: a reference implementation used to validate
  the contract suite itself, plus a useful test double for higher-level
  service tests later in the milestone.
- in-memory-store.test.js: wires the in-memory implementation into the
  contract.

Backups, file paths, and other implementation-specific concerns are
deliberately not in the contract — they live in each store's own tests.
@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from 1513dbd to c173d01 Compare May 6, 2026 10:46
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Round-4 fixes pushed (head c173d01):

  • file-store.js — when the post-write _backup() of a legacy yaml fails, the freshly-written redirects.json is rolled back so the canonical read returns the surviving yaml consistently rather than an unreachable new json. New test simulates the failure and asserts the rollback.
  • store-contract.js — added a getAll() defensive-copy contract test so any store implementation must hand callers data they can mutate without affecting internal state. Mirrors the existing replaceAll input-mutation test.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

🧩 Analysis chain

Let me verify the round-4 fixes before triggering the review.


🏁 Script executed:

#!/bin/bash
echo "=== file-store.js ==="
fd -e js file-store.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== store-contract.js ==="
fd -e js store-contract.js --search-path ghost/core/test | xargs cat

echo ""
echo "=== file-store.test.js (for new rollback test) ==="
fd -e js file-store.test.js --search-path ghost/core/test | xargs cat

Length of output: 18689


@allouis: Both round-4 fixes verified against the current source. Here's the breakdown:

Fix Status Evidence
file-store.js yaml-backup rollback ✅ Confirmed In the different-path branch of replaceAll, _backup() is wrapped in try/catch. On failure, fs.remove(targetPath).catch(() => {}) removes the newly written redirects.json before re-throwing — leaving the legacy yaml as the sole canonical file. The catch(() => {}) on the remove is appropriate: if the remove also fails the error is still surfaced to the operator via the re-throw. The new test stubs store._backup to reject, then asserts redirects.json does not exist and getAll() returns the old yaml content.
store-contract.js getAll() defensive-copy test ✅ Confirmed The new test 'returns data that callers cannot mutate in place' stores a redirect, calls getAll, pushes a new entry to the returned array and mutates firstRead[0].to, then asserts a subsequent getAll still returns the original [{from: '/a', to: '/b', permanent: true}]. This mirrors the existing replaceAll input-mutation test and ensures all store implementations enforce isolation in both directions.

All round-1 through round-4 findings are accounted for. Triggering a fresh review of the force-pushed stack.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
ghost/core/core/server/services/custom-redirects/redirect-config-parser.js (1)

85-91: 💤 Low value

Consider validating that section[from] is a string before pushing.

The to value extracted from YAML could be a non-string (number, object, etc.). While downstream validation.validate() catches this, rejecting early in parseYaml would provide clearer error context (pointing to the YAML structure rather than the redirect entry).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/custom-redirects/redirect-config-parser.js`
around lines 85 - 91, The loop that builds redirect entries currently pushes
section[from] into the redirect object without type-checking; update the loop in
parseYaml (the block that iterates "for (const from in section)" and calls
redirects.push(...)) to validate that section[from] is a string before
pushing—if it's not a string, throw a clear Error (or return a validation error)
that includes the redirect key (from) and the actual typeof section[from] so the
error points to the YAML structure (e.g., "Invalid redirect target for '<from>':
expected string but got <type>"). Ensure you reference the same redirects array
and maintain the existing permanent/statusCode logic.
ghost/core/test/unit/server/services/custom-redirects/file-store.test.js (1)

151-174: ⚡ Quick win

This collision test never proves the stale backup was replaced.

Right now it only checks that the second write succeeds and the canonical state is updated. Because the recreated YAML content is identical on both passes, an implementation that leaves fixedBackup untouched would still pass. Make the second YAML payload distinct and assert fixedBackup contains that second payload.

💡 Tighten the collision assertion
             await fs.writeFile(yamlPath, '301:\n  /a/: /b/\n', 'utf-8');
             await store.replaceAll([{from: '/first', to: '/x', permanent: true}]);
             // Re-create a yaml so the next replaceAll has something to
             // back up onto the same fixedBackup target.
-            await fs.writeFile(yamlPath, '301:\n  /a/: /b/\n', 'utf-8');
+            await fs.writeFile(yamlPath, '301:\n  /second-backup/: /z/\n', 'utf-8');
             await store.replaceAll([{from: '/second', to: '/y', permanent: true}]);
 
+            assert.equal(
+                await fs.readFile(fixedBackup, 'utf-8'),
+                '301:\n  /second-backup/: /z/\n'
+            );
             assert.deepEqual(await store.getAll(), [
                 {from: '/second', to: '/y', permanent: true}
             ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/unit/server/services/custom-redirects/file-store.test.js`
around lines 151 - 174, The test for FileStore's collision behavior should
verify the fixed backup file was actually overwritten: when using the injected
getBackupFilePath (fixedBackup) make the second fs.writeFile to yamlPath write a
distinct payload (different from the first), then after the second
store.replaceAll read the fixedBackup file and assert its contents match the
second payload (in addition to the existing assert on store.getAll); reference
FileStore, getBackupFilePath, replaceAll, fixedBackup, yamlPath and store.getAll
to locate where to change the writes and add the backup-file read/assertion.
ghost/core/test/e2e-api/admin/redirects.test.js (1)

55-71: ⚡ Quick win

Add an end-to-end upload case for YAML input.

This suite now locks in the YAML download change, but it still only exercises JSON uploads. The new logic in this PR is the extension-based YAML/JSON parsing path, so a .yaml/.yml upload should be covered here too or this e2e can stay green while the main changed path regresses.

💡 Suggested test shape
+    it('upload yaml', function () {
+        const filePath = path.join(config.get('paths:contentPath'), 'redirects-init.yml');
+        fs.writeFileSync(filePath, '301:\n  /k: /l\n');
+
+        return request
+            .post(localUtils.API.getApiQuery('redirects/upload/'))
+            .set('Origin', config.get('url'))
+            .attach('redirects', filePath)
+            .expect('Content-Type', /application\/json/)
+            .expect(200)
+            .expect((res) => {
+                assert.deepEqual(res.body, {});
+            });
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/e2e-api/admin/redirects.test.js` around lines 55 - 71, Update
the e2e test in redirects.test.js to also exercise YAML uploads: in the existing
it('upload', ...) test (and/or add a new it block) write a redirects-init.yaml
(or .yml) file into the contentPath using YAML syntax for the same redirect
array, then call request.post(localUtils.API.getApiQuery('redirects/upload/'))
and .attach('redirects', path.join(config.get('paths:contentPath'),
'redirects-init.yaml')) (or .yml), keeping the same headers and assertions
(expect JSON response 200 and assert.deepEqual(res.body, {})) so the
extension-driven YAML/JSON parsing path is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ghost/core/core/server/services/custom-redirects/redirect-config-parser.js`:
- Around line 85-91: The loop that builds redirect entries currently pushes
section[from] into the redirect object without type-checking; update the loop in
parseYaml (the block that iterates "for (const from in section)" and calls
redirects.push(...)) to validate that section[from] is a string before
pushing—if it's not a string, throw a clear Error (or return a validation error)
that includes the redirect key (from) and the actual typeof section[from] so the
error points to the YAML structure (e.g., "Invalid redirect target for '<from>':
expected string but got <type>"). Ensure you reference the same redirects array
and maintain the existing permanent/statusCode logic.

In `@ghost/core/test/e2e-api/admin/redirects.test.js`:
- Around line 55-71: Update the e2e test in redirects.test.js to also exercise
YAML uploads: in the existing it('upload', ...) test (and/or add a new it block)
write a redirects-init.yaml (or .yml) file into the contentPath using YAML
syntax for the same redirect array, then call
request.post(localUtils.API.getApiQuery('redirects/upload/')) and
.attach('redirects', path.join(config.get('paths:contentPath'),
'redirects-init.yaml')) (or .yml), keeping the same headers and assertions
(expect JSON response 200 and assert.deepEqual(res.body, {})) so the
extension-driven YAML/JSON parsing path is covered.

In `@ghost/core/test/unit/server/services/custom-redirects/file-store.test.js`:
- Around line 151-174: The test for FileStore's collision behavior should verify
the fixed backup file was actually overwritten: when using the injected
getBackupFilePath (fixedBackup) make the second fs.writeFile to yamlPath write a
distinct payload (different from the first), then after the second
store.replaceAll read the fixedBackup file and assert its contents match the
second payload (in addition to the existing assert on store.getAll); reference
FileStore, getBackupFilePath, replaceAll, fixedBackup, yamlPath and store.getAll
to locate where to change the writes and add the backup-file read/assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1d9befa2-3d8d-47d5-92e9-1d644274005b

📥 Commits

Reviewing files that changed from the base of the PR and between 1513dbd and c173d01.

📒 Files selected for processing (18)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/core/server/services/custom-redirects/types.js
  • ghost/core/core/server/services/custom-redirects/validation.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/in-memory-store.js
  • ghost/core/test/unit/server/services/custom-redirects/helpers/store-contract.js
  • ghost/core/test/unit/server/services/custom-redirects/in-memory-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
  • ghost/core/test/unit/server/services/custom-redirects/validation.test.js
💤 Files with no reviewable changes (2)
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js

allouis added 4 commits May 6, 2026 10:54
ref https://linear.app/ghost/issue/HKG-1693

Pulls the YAML/JSON parsing logic out of CustomRedirectsAPI (lines
54-114 of custom-redirects-api.js) into a standalone module so that the
upcoming FileStore and the API endpoint can both reuse it without
depending on the legacy class. Also adds the YAML serialisation that
HKG-1697 will need on the download path — it does not exist anywhere in
the current codebase.

- parseJson / parseYaml: behaviour preserved from the existing
  parseRedirectsFile, including the BadRequestError shape and the
  ordering quirk where 302 entries appear before 301 entries (matches
  current init() output, so nothing observable changes).
- parseYaml additionally rejects top-level YAML lists, scalars, and
  non-mapping `301:` / `302:` sections. Without these guards an
  arbitrary list-shaped upload would parse to zero redirects and
  silently wipe the live config via replaceAll([]) on the upload path.
  Empty status-code sections (`301:` followed by nothing, common in
  hand-edited files) are tolerated and contribute zero redirects.
- Empty input throws BadRequestError. The legacy parseRedirectsFile
  threw on JSON.parse('') and yaml.load('')→null, and FileStore.getAll
  reaches the parser only when the file actually exists, so this
  preserves the legacy behaviour where a zero-byte redirects file
  surfaces as a logged error at boot rather than a silent zero-redirect
  state.
- serializeToYaml: section headers (`301:` / `302:`) are emitted by
  hand because js-yaml quotes numeric-string keys to disambiguate them
  from integers, which would give us `"301":` instead of `301:` and
  break visual parity with every YAML redirects file Ghost has shipped
  to date. Each `from: to` pair underneath is dumped via js-yaml so
  escaping, block scalars, and quoting of special characters in the
  redirect fields (colons, hashes, leading dashes, multi-line values)
  are handled correctly.

custom-redirects-api.js is not yet updated to use this module; that
swap happens later in the milestone after the service/store split is in
place. Keeping this commit purely additive means the legacy class
keeps passing its tests unchanged.
ref https://linear.app/ghost/issue/HKG-1694

The first concrete implementation of the RedirectsStore contract. Self-
hosters use this permanently, so backward compatibility with existing
yaml/json files on disk is non-negotiable. The contract suite from
HKG-1692 plugs in directly to verify behaviour.

- getAll(): reads `redirects.yaml` if present, falling back to
  `redirects.json`. Same precedence as the existing CustomRedirectsAPI,
  so a self-hosted upgrade is an in-place swap with no on-disk migration.
- replaceAll(): always writes `redirects.json` regardless of what was
  previously on disk. The legacy yaml file (if any) is moved to a
  timestamped backup. After the first upload on a yaml-based instance,
  subsequent reads come from the json file. YAML comments are lost on
  that one-shot conversion — accepted tradeoff per HKG-1694.
- Atomic writes via temp-file + rename. fs.rename is atomic on POSIX
  within the same filesystem, so a process crash mid-write either
  leaves the previous file intact or the new file fully written. The
  scratch file is best-effort cleaned up on rename failure.
- Backup collisions inside the same second overwrite the older backup
  rather than failing — matches the legacy behaviour and avoids a
  surprising error on rapid successive uploads.

CustomRedirectsAPI still exists and is still wired in via index.js;
it's swapped out in a later commit once the orchestrating service is
in place.
ref https://linear.app/ghost/issue/HKG-1695

Splits storage from activation so the in-memory router can be rebuilt
from the store without writing a file. Today's setFromFilePath couples
the two: there is no way to say "reload from disk" without an upload.
The future cross-instance notification project (out of scope for this
milestone) needs activation as a standalone operation, and laying the
seam now means it can be wired up without touching this service.

- activate(): read from store, then clear+rebuild the
  DynamicRedirectManager. Per-item validation: a single bad redirect
  (bad regex, missing field) is logged and skipped without aborting the
  rest of the batch. Today's behaviour is harsher — one bad rule kills
  the whole config — so this is a deliberate shift toward resilience on
  the read path. Store-level errors bubble up so callers can decide.
- replace(): batch-validates first (so a publisher upload either
  succeeds entirely or rejects without persisting), then store.replaceAll
  + activate.
- init(): the boot wrapper. Catches every error, distinguishing Ghost
  errors from unwrapped ones for consistent logging shape, matching
  CustomRedirectsAPI.init's existing graceful-degradation policy.
- The DynamicRedirectManager instance is mutated, never replaced, because
  site.js wires `redirectManager.handleRequest` into Express at boot —
  swapping the manager would dangle that reference.

The service is not yet wired into index.js — the swap is the next
commit.
ref https://linear.app/ghost/issue/HKG-1696

Switches the Ghost runtime from CustomRedirectsAPI to the new
RedirectsService + FileStore pair. boot.js and site.js are not touched
— the module still exports `{ init(), api, middleware }` so the blast
radius stays inside this directory.

- index.js: composes FileStore + DynamicRedirectManager + validate into
  a RedirectsService, drops the import of the legacy class. The legacy
  file itself is left in the tree until the dead-code sweep that
  follows; nothing imports it now.
- api/endpoints/redirects.js: switches to the service's `getAll`/`replace`
  methods and parses uploads at the boundary using the parser util. The
  download path is intentionally left as JSON-with-`redirects.json`-
  filename for this commit; HKG-1697 will flip it to YAML in the next
  commit and update the e2e test alongside.
- test/legacy/api/admin/redirects.test.js: the existing yaml-on-disk
  assertion no longer holds because FileStore writes JSON regardless of
  upload format. Updated to assert that the canonical redirects.json
  contains the parsed config and that a timestamped backup is created
  on overwrite — the observable contract for self-hosters.

Verified by running the redirects unit tests, the
test/e2e-api/admin/redirects.test.js download/upload pair, and the
legacy integration test.
@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from c173d01 to 1962ffb Compare May 6, 2026 10:57
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Round-5 nits pushed (head 1962ffb):

  • redirect-config-parser.js — parseYaml now rejects non-string redirect targets (: 123, : ~) at parse time so the error points at the YAML structure rather than a coerced entry. Tests added for both cases.
  • file-store.test.js — collision test now uses distinct yaml payloads on the two passes and asserts the stable backup path contains the second-pass content. The previous assertion couldn't actually distinguish overwrite from no-op.
  • e2e-api/admin/redirects.test.js — added upload (yaml) case so the extension-driven YAML branch is covered alongside the existing JSON upload.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

🧩 Analysis chain

Let me verify the round-5 fixes before triggering the review.


🏁 Script executed:

#!/bin/bash
echo "=== redirect-config-parser.js ==="
fd -e js redirect-config-parser.js --search-path ghost/core/core/server/services/custom-redirects | xargs cat

echo ""
echo "=== redirect-config-parser.test.js (for new non-string target tests) ==="
fd -e js redirect-config-parser.test.js --search-path ghost/core/test | xargs cat

echo ""
echo "=== file-store.test.js (for updated collision test) ==="
fd -e js file-store.test.js --search-path ghost/core/test | xargs cat

echo ""
echo "=== e2e-api/admin/redirects.test.js (for new yaml upload case) ==="
fd -e js redirects.test.js --search-path ghost/core/test/e2e-api | xargs cat

Length of output: 32408


@allouis: All three round-5 fixes verified against the current source. Here's the breakdown:

Fix Status Evidence
redirect-config-parser.js non-string target rejection ✅ Confirmed typeof to !== 'string' guard is in place inside the for (const from in section) loop, throwing BadRequestError immediately. Both : 123 (integer) and : ~ (null YAML) are covered by the new test 'throws BadRequestError when a redirect target is not a string', which asserts BadRequestError for each case within a single it block.
file-store.test.js collision test distinction ✅ Confirmed The two passes now use firstYaml = '301:\n /first-backup/: /x/\n' and secondYaml = '301:\n /second-backup/: /z/\n' — distinct payloads. The final assertion assert.equal(await fs.readFile(fixedBackup, 'utf-8'), secondYaml, ...) confirms the backup contains the second-pass content specifically, ruling out the no-op case. The inline comment explains why identical content would have been insufficient.
e2e-api/admin/redirects.test.js YAML upload case ✅ Confirmed New 'upload (yaml)' test writes '301:\n /yaml-from/: /yaml-to/\n' to a .yaml file, attaches it to the upload endpoint, and asserts 200 with an empty JSON body. Comment explicitly calls out the intent: "a regression on the JSON path doesn't quietly hide the missing YAML coverage."

All round-1 through round-5 findings are accounted for. Triggering a fresh review of the force-pushed stack.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/admin/redirects.test.js (1)

80-88: ⚡ Quick win

Assert upload side effects, not just status/body.

At Line 86, this test only checks 200 + {}. A silent no-op upload could still pass. Please add a follow-up read assertion (e.g., download and confirm /yaml-from/ -> /yaml-to/ exists) so the YAML path is functionally validated.

Suggested test hardening
-    it('upload (yaml)', function () {
+    it('upload (yaml)', async function () {
         // The endpoint chooses parser by file extension. Cover the
         // YAML branch explicitly so a regression on the JSON path
         // doesn't quietly hide the missing YAML coverage.
         const filePath = path.join(config.get('paths:contentPath'), 'redirects-init.yaml');
         fs.writeFileSync(filePath, '301:\n  /yaml-from/: /yaml-to/\n');

-        return request
+        await request
             .post(localUtils.API.getApiQuery('redirects/upload/'))
             .set('Origin', config.get('url'))
             .attach('redirects', filePath)
             .expect('Content-Type', /application\/json/)
             .expect(200)
             .expect((res) => {
                 assert.deepEqual(res.body, {});
             });
+
+        await request
+            .get(localUtils.API.getApiQuery('redirects/download/'))
+            .set('Origin', config.get('url'))
+            .expect(200)
+            .expect((res) => {
+                const redirects = parseYaml(res.text);
+                assert.equal(
+                    redirects.some(r => r.from === '/yaml-from/' && r.to === '/yaml-to/'),
+                    true
+                );
+            });
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/e2e-api/admin/redirects.test.js` around lines 80 - 88, After
the POST to redirects/upload/ (the
request.post(localUtils.API.getApiQuery('redirects/upload/')) call and its
existing assertions), add a follow-up read to verify the uploaded redirect
actually exists: perform a GET (e.g.,
request.get(localUtils.API.getApiQuery('redirects/')) or download endpoint) and
assert that an entry mapping "/yaml-from/" to "/yaml-to/" is present in the
returned redirects list/body; update the test around the .expect((res) => {
assert.deepEqual(res.body, {}); }) block to include this additional GET and
assertion so the upload side effect is validated, not just the 200 and empty
body.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ghost/core/test/e2e-api/admin/redirects.test.js`:
- Around line 80-88: After the POST to redirects/upload/ (the
request.post(localUtils.API.getApiQuery('redirects/upload/')) call and its
existing assertions), add a follow-up read to verify the uploaded redirect
actually exists: perform a GET (e.g.,
request.get(localUtils.API.getApiQuery('redirects/')) or download endpoint) and
assert that an entry mapping "/yaml-from/" to "/yaml-to/" is present in the
returned redirects list/body; update the test around the .expect((res) => {
assert.deepEqual(res.body, {}); }) block to include this additional GET and
assertion so the upload side effect is validated, not just the 200 and empty
body.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cd25e5d5-13a4-4e8c-91cf-476ae9c0f8d5

📥 Commits

Reviewing files that changed from the base of the PR and between c173d01 and 1962ffb.

📒 Files selected for processing (12)
  • ghost/core/core/server/api/endpoints/redirects.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
  • ghost/core/core/server/services/custom-redirects/file-store.js
  • ghost/core/core/server/services/custom-redirects/index.js
  • ghost/core/core/server/services/custom-redirects/redirect-config-parser.js
  • ghost/core/core/server/services/custom-redirects/redirects-service.js
  • ghost/core/test/e2e-api/admin/redirects.test.js
  • ghost/core/test/legacy/api/admin/redirects.test.js
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirect-config-parser.test.js
  • ghost/core/test/unit/server/services/custom-redirects/redirects-service.test.js
💤 Files with no reviewable changes (2)
  • ghost/core/test/unit/server/services/custom-redirects/api.test.js
  • ghost/core/core/server/services/custom-redirects/custom-redirects-api.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/services/custom-redirects/file-store.test.js

allouis added 2 commits May 6, 2026 11:07
ref https://linear.app/ghost/issue/HKG-1697

Changelog: when downloading the custom redirects config, Ghost now
always returns a YAML file (\`redirects.yaml\`) regardless of which
format was originally uploaded. Previously, JSON uploads downloaded as
\`redirects.json\` and YAML uploads downloaded as \`redirects.yaml\`,
which made the download ↔ edit ↔ re-upload loop awkward when the format
varied. YAML is the recommended format per Ghost docs and the only
format the existing CLI tooling generates examples for, so making it
consistent removes a small papercut.

Note: any comments, anchors, or whitespace in a hand-edited
\`redirects.yaml\` on disk are lost as soon as the publisher uploads or
downloads via the admin API. The on-disk file is now the canonical
\`redirects.json\` (FileStore writes JSON regardless of upload format),
and downloads reconstruct YAML from the parsed structure. Self-hosters
who manage \`redirects.yaml\` directly on disk and never use the admin
upload endpoint are unaffected.

Endpoint changes:
- download: serializes the service result via serializeToYaml,
  Content-Disposition is \`redirects.yaml\`, response format is plain so
  the framework writes the string verbatim, Content-Type is
  application/yaml via the existing yaml disposition helper.
- The service layer no longer receives file paths or raw file content
  on either path — it only ever sees \`RedirectConfig[]\`. That seals
  the boundary the milestone has been working toward.

Test updates:
- e2e admin redirects download test now asserts the YAML
  Content-Disposition + Content-Type and round-trips the body through
  parseYaml for a structural compare. Comparing byte-for-byte would
  bake in our specific YAML formatting (key quoting, section order)
  which is incidental, not contract.
ref https://linear.app/ghost/issue/HKG-1698

All call sites moved to RedirectsService + FileStore in earlier
commits. The legacy class and its unit-test file are now dead weight
in the redirects service directory and confuse future readers.

- core/server/services/custom-redirects/custom-redirects-api.js: gone.
- test/unit/server/services/custom-redirects/api.test.js: gone. Its
  characterization tests were behaviour-level (init activates from
  disk, validate runs before write, no backup when no existing file,
  validation failure short-circuits) and the equivalent assertions now
  live in redirects-service.test.js / file-store.test.js against the
  new abstractions.
- utils.js (getBackupRedirectsFilePath) is preserved — FileStore uses it.

\`grep -r CustomRedirectsAPI\` and \`grep -r custom-redirects-api\` both
return zero results across the codebase post-deletion.
@allouis allouis force-pushed the fabien/diskless-redirects-phase-1 branch from 1962ffb to 2ed05af Compare May 6, 2026 11:08
@allouis
Copy link
Copy Markdown
Collaborator Author

allouis commented May 6, 2026

Round-6 nit pushed (head 2ed05af):

  • e2e upload (yaml) test now follows the upload with a download and asserts the just-uploaded entry round-trips through the live config. Status + body alone couldn't distinguish a successful upload from a silent no-op.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

🧩 Analysis chain

Let me verify the round-6 fix before triggering the review.


🏁 Script executed:

#!/bin/bash
echo "=== e2e-api/admin/redirects.test.js ==="
fd -e js redirects.test.js --search-path ghost/core/test/e2e-api | xargs cat

Length of output: 5229


@allouis: Round-6 fix confirmed. Here's the breakdown:

Fix Status Evidence
e2e-api/admin/redirects.test.js YAML upload round-trip ✅ Confirmed After the 200 upload assertion, a subsequent GET redirects/download/ is made and the response is parsed with parseYaml. The assertion redirects.some(r => r.from === '/yaml-from/' && r.to === '/yaml-to/' && r.permanent === true) checks the specific triple that was just uploaded — it cannot pass on stale state, and the error message on failure includes the raw response text for easy diagnosis. The inline comment explicitly states the rationale for the two-phase approach.

One small observation: permanent: true in the assertion correctly corresponds to the 301: section uploaded — parseYaml maps 301 entries to permanent: true, so the expectation is semantically precise. No issues found.

All round-1 through round-6 findings are accounted for. Triggering a fresh review of the force-pushed stack.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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