Skip to content

Add nested routes support in the RestApiHandler#2454

Open
lsmith77 wants to merge 3 commits intozenstackhq:devfrom
lsmith77:nested-routes
Open

Add nested routes support in the RestApiHandler#2454
lsmith77 wants to merge 3 commits intozenstackhq:devfrom
lsmith77:nested-routes

Conversation

@lsmith77
Copy link
Contributor

@lsmith77 lsmith77 commented Mar 5, 2026

fixes #2453

Note: this was largely AI developed. I wanted to offer it as a more concrete proposal than just the above referenced ticket. If you agree this feature has merit, I will do a more detailed analysis of the proposed code changes.

TOCTOU Race Condition in Nested Mutations

TOCTOU = Time-of-Check to Time-of-Use

The nested update/delete operations verify child-parent membership using a merged filter, then perform the mutation using only the child ID. In a concurrent scenario where the parent relationship changes between check and mutation, the operation could target an orphaned or reassigned resource.

Options Explored:

  • Direct mutations with merged filter (ATTEMPTED, FAILED): Have processNestedUpdate/processNestedDelete build and execute mutations directly using the merged filter for atomicity.
    • ❌ Result: Passing the merged filter to the ORM's update() and delete() calls resulted in 422 (Unprocessable Entity) errors. Not viable with current ORM implementation.
  • Optional where parameter (NOT ATTEMPTED): Add optional where parameter to processUpdate/processDelete to allow merged filter injection from nested handlers.
    • ⚠️ Trade-off: Would modify widely-used public methods, affecting all CRUD operations globally—higher risk for unintended consequences across the codebase. Still theoretically viable but rejected for minimal impact principle.
  • Format adaptation (NOT ATTEMPTED): Investigate merged filter semantics and adapt the format for direct ORM use.
    • ⚠️ Trade-off: Would require deep ORM integration work and understanding internal filter representations. Still theoretically viable but high effort/risk ratio.

Why Not Fixed:

  • Option 1 definitively failed due to ORM incompatibility
  • Options 2-3 rejected to keep PR scope minimal and reduce cross-cutting impact
  • Can't easily expose the race condition in tests (requires concurrent modifications)

Mitigation:

The authorization check prevents unauthorized cross-parent access (the primary security concern). The window for this race is extremely narrow and requires concurrent parent-relationship changes—a rare operation pattern.

Summary by CodeRabbit

  • New Features

    • Configurable nested REST routes for parent-child resources: scoped reads/writes, nested single resource URLs, path-aware link generation, and enforced parent binding on nested creates/updates.
  • Bug Fixes

    • Fixed range/filter parsing issue.
  • Tests

    • Added extensive tests covering nested-route validation, orphan-protection semantics, routing behavior, mappings, and CRUD operations.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

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

Walkthrough

Adds explicit nested route support to RestApiHandler via a new nestedRoutes option, constructor-time validation, nested URL pattern NESTED_SINGLE, nested-aware routing and handlers for collection/single read/create/update/delete, and corresponding tests.

Changes

Cohort / File(s) Summary
Core REST handler
packages/server/src/api/rest/index.ts
Introduce nestedRoutes in RestApiHandlerOptions, add UrlPatterns.NESTED_SINGLE, normalize/validate nested configs at construction, extend URL matching/routing for nested collection and single reads, add nested create/update/delete processing and helper functions, propagate nested context (extraFilter/pathOverride/resourceLinker) for queries and link generation, and minor Between filter split fix.
Options validation tests
packages/server/test/api/options-validation.test.ts
Add tests covering shape and semantic validation of nestedRoutes: invalid types, missing models/relations, relation target mismatches, and requireOrphanProtection enforcement for various onDelete behaviors.
Integration tests (nested routes)
packages/server/test/api/rest.test.ts
Add end-to-end nested route tests: parent-scoped reads, 404s for missing parents or mismatched ownership, POST auto-binding and rejection when payload alters parent, scoped PATCH/DELETE, filtering/pagination, fallback for non-configured 3-segment paths, JSON:API nested self-links, and modelNameMapping scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰
I hop where parents meet their young,
I stitch each path so links stay strung.
I bind the child and guard the way,
No orphaned route will dare to stray.
Huzzah — nested endpoints leap and play!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add nested routes support in the RestApiHandler' directly and clearly describes the primary change in the changeset.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #2453: explicit nested route configuration, hierarchical URL patterns for all HTTP methods, parent scoping, 404 on missing parent/child, automatic parent binding on POST, flat route preservation, JSON:API self-links in nested responses, requireOrphanProtection validation, configuration error handling, and support for modelNameMapping and externalIdMapping.
Out of Scope Changes check ✅ Passed All changes directly support the nested routes feature: RestApiHandlerOptions extends configuration, UrlPatterns adds NESTED_SINGLE pattern, RestApiHandler implements nested routing logic, and comprehensive tests validate configurations and behaviors per issue #2453 requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Tip

CodeRabbit can use OpenGrep to find security vulnerabilities and bugs across 17+ programming languages.

OpenGrep is compatible with Semgrep configurations. Add an opengrep.yml or semgrep.yml configuration file to your project to enable OpenGrep analysis.

Copy link
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: 3

🧹 Nitpick comments (1)
packages/server/test/api/rest.test.ts (1)

3932-3972: Strengthen mapped nested-route coverage with mutation + link assertions.

This block currently checks mapped GET only. Adding mapped POST/PATCH/DELETE assertions (plus nested links.self) would protect against mapped-segment dispatch regressions.

🧪 Example assertion to add
+            const created = await mappedHandler({
+                method: 'post',
+                path: '/users/u1/posts',
+                client: nestedClient,
+                requestBody: {
+                    data: { type: 'Post', attributes: { title: 'mapped-created' } },
+                },
+            });
+            expect(created.status).toBe(201);
+            expect(created.body.data.links.self).toContain('/users/u1/posts/');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/test/api/rest.test.ts` around lines 3932 - 3972, The test
only asserts mapped GET behavior; add assertions that exercise mapped
nested-route mutations and link metadata to prevent dispatch regressions: use
RestApiHandler with modelNameMapping and nestedRoutes (the mappedApi and
mappedHandler helpers) to send POST to /users/u1/posts (create a child), PATCH
to update that child via /users/u1/posts/{id}, and DELETE to remove it,
asserting status codes and that nested list changes accordingly; additionally
assert the response includes nested links.self pointing to the mapped nested URL
(e.g., /users/{parentId}/posts) after each relevant response to verify link
wiring.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server/src/api/rest/index.ts`:
- Around line 1562-1578: The code currently only rejects setting the
parent-controlled relation via relationships but still allows scalar FK
attributes; update the validation inside the forcedParentRelation block to also
detect any scalar FK fields from forcedParentRelation.parentIdFields present in
createPayload.data and return the same makeError when found. In practice, in the
forcedParentRelation branch check createPayload.data for any keys matching each
entry in forcedParentRelation.parentIdFields (in addition to the existing
relationships check) and call this.makeError('invalidPayload', ...) if any are
present; otherwise proceed to set
createPayload.data[forcedParentRelation.relation] = { connect:
this.makeIdConnect(...) } and include the select via this.makeDefaultIdKey(...)
as before.
- Around line 1303-1311: After building the nested parent filter in the
nested-create flow, add an explicit existence check for the parent before
performing the mutation: use getModelInfo(parentType) to get the parent model
metadata, reuse the filter produced by buildNestedParentFilter(parentType,
parentId, ...) to query the underlying data store via your model/adapter read
method, and if the parent row is not found return a 404-style error (consistent
with your API) instead of proceeding; this ensures nested POST returns an
explicit "parent not found" response rather than relying on downstream ORM
errors.
- Around line 1366-1373: The current guard only checks body.data.relationships
and uses the `in` operator unsafely; update the nested-patch guard in the
handler that inspects requestBody to (1) ensure body?.data?.relationships is an
object before using `in` (e.g., typeof === 'object' && body.data.relationships
!== null) or use hasOwnProperty, and (2) also inspect body?.data?.attributes for
scalar FK fields that could re-parent (cover common patterns for the FK name
derived from nestedRouteConfig.relation such as
`${nestedRouteConfig.relation}Id` and `${nestedRouteConfig.relation}_id`) and
call this.makeError('invalidPayload', ...) if any of those are present;
reference nestedRouteConfig.relation, requestBody/body, body.data.relationships,
body.data.attributes, and this.makeError to locate and update the logic.

---

Nitpick comments:
In `@packages/server/test/api/rest.test.ts`:
- Around line 3932-3972: The test only asserts mapped GET behavior; add
assertions that exercise mapped nested-route mutations and link metadata to
prevent dispatch regressions: use RestApiHandler with modelNameMapping and
nestedRoutes (the mappedApi and mappedHandler helpers) to send POST to
/users/u1/posts (create a child), PATCH to update that child via
/users/u1/posts/{id}, and DELETE to remove it, asserting status codes and that
nested list changes accordingly; additionally assert the response includes
nested links.self pointing to the mapped nested URL (e.g.,
/users/{parentId}/posts) after each relevant response to verify link wiring.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28be15c8-cc27-4592-8c39-1e2203f23b8d

📥 Commits

Reviewing files that changed from the base of the PR and between 75d77de and 228bd2e.

📒 Files selected for processing (3)
  • packages/server/src/api/rest/index.ts
  • packages/server/test/api/options-validation.test.ts
  • packages/server/test/api/rest.test.ts

Copy link
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.

♻️ Duplicate comments (1)
packages/server/src/api/rest/index.ts (1)

1373-1390: ⚠️ Potential issue | 🟠 Major

Harden nested PATCH FK guard using schema-derived FK fields (not naming heuristics).

Line 1383 only checks authorId / author_id-style keys. Custom or compound FK fields can still re-parent through attributes, bypassing nested route constraints.

💡 Proposed fix
 const rel = nestedRouteConfig.relation;
@@
-const fkCandidates = [`${rel}Id`, `${rel}_id`];
+const fkCandidates = [
+    ...(this.schema.models[typeInfo.name]?.fields[rel]?.relation?.fields ?? []),
+    ...Object.values(typeInfo.fields)
+        .filter((f) => f.foreignKeyFor?.includes(rel))
+        .map((f) => f.name),
+];
 if (
     typeof body?.data?.attributes === 'object' &&
     body.data.attributes !== null &&
     fkCandidates.some((fk) => Object.prototype.hasOwnProperty.call(body.data.attributes, fk))
 ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/src/api/rest/index.ts` around lines 1373 - 1390, The nested
PATCH guard currently only checks heuristic FK names and can be bypassed; update
the check in the handler that uses requestBody/body to compute FK candidate
fields from the schema/nestedRouteConfig instead of hardcoded
`${rel}Id`/`${rel}_id`: call the schema or ORM helper that returns actual
foreign-key field names for nestedRouteConfig.relation (e.g., a method like
schema.getForeignKeyFieldsForRelation or nestedRouteConfig.foreignKeys), then
test those real FK field names against body.data.attributes and
body.data.relationships and return this.makeError('invalidPayload', ...) if any
are present; keep the existing early-return behavior and use
nestedRouteConfig.relation and makeError symbols to locate and update the logic.
🧹 Nitpick comments (1)
packages/server/test/api/rest.test.ts (1)

4022-4062: Add mapped nested-create coverage to prove mapped 3-segment nested routing.

This block currently validates mapped GET /users/:id/posts, which can still pass via non-nested fallback behavior. Add a mapped nested create assertion (POST /users/:id/posts) and verify nested self-links to ensure mapped nested routing is truly exercised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/test/api/rest.test.ts` around lines 4022 - 4062, Add a mapped
nested-create assertion and self-link checks to ensure mapped 3-segment nested
routing is exercised: use the existing mappedHandler created from
RestApiHandler/mappedApi to POST to '/users/u1/posts' (create a post for u1),
assert the response status is 201 (or created) and the returned resource
contains the expected attributes (title 'mapped-post' or similar), then fetch
the collection via GET '/users/u1/posts' (or inspect the POST response) and
assert the nested self-links/relationships reference the mapped route (e.g.,
self or related links include '/users/u1/posts') and that the created post
appears in the collection; reference mappedApi, mappedHandler and nestedClient
when adding these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/server/src/api/rest/index.ts`:
- Around line 1373-1390: The nested PATCH guard currently only checks heuristic
FK names and can be bypassed; update the check in the handler that uses
requestBody/body to compute FK candidate fields from the
schema/nestedRouteConfig instead of hardcoded `${rel}Id`/`${rel}_id`: call the
schema or ORM helper that returns actual foreign-key field names for
nestedRouteConfig.relation (e.g., a method like
schema.getForeignKeyFieldsForRelation or nestedRouteConfig.foreignKeys), then
test those real FK field names against body.data.attributes and
body.data.relationships and return this.makeError('invalidPayload', ...) if any
are present; keep the existing early-return behavior and use
nestedRouteConfig.relation and makeError symbols to locate and update the logic.

---

Nitpick comments:
In `@packages/server/test/api/rest.test.ts`:
- Around line 4022-4062: Add a mapped nested-create assertion and self-link
checks to ensure mapped 3-segment nested routing is exercised: use the existing
mappedHandler created from RestApiHandler/mappedApi to POST to '/users/u1/posts'
(create a post for u1), assert the response status is 201 (or created) and the
returned resource contains the expected attributes (title 'mapped-post' or
similar), then fetch the collection via GET '/users/u1/posts' (or inspect the
POST response) and assert the nested self-links/relationships reference the
mapped route (e.g., self or related links include '/users/u1/posts') and that
the created post appears in the collection; reference mappedApi, mappedHandler
and nestedClient when adding these assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 32ecfa26-53fe-43ca-944c-46d23f105cae

📥 Commits

Reviewing files that changed from the base of the PR and between 228bd2e and b1c1b54.

📒 Files selected for processing (3)
  • packages/server/src/api/rest/index.ts
  • packages/server/test/api/options-validation.test.ts
  • packages/server/test/api/rest.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server/test/api/options-validation.test.ts

@lsmith77 lsmith77 force-pushed the nested-routes branch 2 times, most recently from 80c9dd6 to 8c9a4a1 Compare March 5, 2026 14:21
Copy link
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

🧹 Nitpick comments (1)
packages/server/test/api/rest.test.ts (1)

3996-4036: Add a regression test for mapped child names without nestedRoutes.

This block validates mapped nested routes, but it doesn’t cover the flat-route fallback case when modelNameMapping is set and nestedRoutes is absent. Please add a case like GET /users/:id/posts (with Post -> posts mapping, no nested config) to ensure regular fetchRelated stays intact.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/test/api/rest.test.ts` around lines 3996 - 4036, Add a new
regression test in the same suite that verifies mapped child names work when
modelNameMapping is present but nestedRoutes is omitted: instantiate
RestApiHandler with schema: nestedClient.$schema, endpoint, and
modelNameMapping: { User: 'users', Post: 'posts' } but no nestedRoutes, create
users and a post via nestedClient.user.create, then call the handler (reuse
mappedHandler pattern) with method 'get' and path `/users/u1/posts` and assert
status 200 and that the returned collection contains the created post, and also
call `/users/u2/posts` and assert it returns an empty list; this ensures the
flat-route fallback (fetchRelated behavior) works for mapped child names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server/src/api/rest/index.ts`:
- Around line 509-513: The code is mutating match.relationship with a model-name
alias which breaks flat 3-segment routes and the processFetchRelated fallback;
instead of rewriting match.relationship, compute a local mappedRelationship
(e.g. const mappedRelationship =
this.reverseModelNameMapping[match.relationship] || match.relationship) and use
that local value only when resolving nested-route model dispatch, leaving
match.relationship untouched so processFetchRelated still receives the original
route segment; apply the same non-mutating change to the other occurrences
around the blocks that reference reverseModelNameMapping (the spots analogous to
the current match.relationship rewrite).
- Line 594: The variable declaration uses let for values that are never
reassigned (e.g., nestedMatch created from this.matchUrlPattern(path,
UrlPatterns.FETCH_RELATIONSHIP)); change these to const. Update the three
occurrences in this module where a value is assigned from this.matchUrlPattern
or similar pattern-matching calls (including the nestedMatch declaration and the
two other let declarations reported) to use const instead of let so they satisfy
the prefer-const lint rule.

---

Nitpick comments:
In `@packages/server/test/api/rest.test.ts`:
- Around line 3996-4036: Add a new regression test in the same suite that
verifies mapped child names work when modelNameMapping is present but
nestedRoutes is omitted: instantiate RestApiHandler with schema:
nestedClient.$schema, endpoint, and modelNameMapping: { User: 'users', Post:
'posts' } but no nestedRoutes, create users and a post via
nestedClient.user.create, then call the handler (reuse mappedHandler pattern)
with method 'get' and path `/users/u1/posts` and assert status 200 and that the
returned collection contains the created post, and also call `/users/u2/posts`
and assert it returns an empty list; this ensures the flat-route fallback
(fetchRelated behavior) works for mapped child names.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5737dc7f-5d4d-47f4-bd8d-c5698cabdc0a

📥 Commits

Reviewing files that changed from the base of the PR and between b1c1b54 and 8c9a4a1.

📒 Files selected for processing (3)
  • packages/server/src/api/rest/index.ts
  • packages/server/test/api/options-validation.test.ts
  • packages/server/test/api/rest.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server/test/api/options-validation.test.ts

if (!requestBody) {
return this.makeError('invalidPayload');
}
let nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

head -n 700 packages/server/src/api/rest/index.ts | tail -n 120 | cat -n

Repository: zenstackhq/zenstack

Length of output: 6416


🏁 Script executed:

sed -n '590,600p;640,650p;680,690p' packages/server/src/api/rest/index.ts | cat -n

Repository: zenstackhq/zenstack

Length of output: 1948


Change let to const for variables that are never reassigned.

Lines 594, 644, and 685 declare variables with let that are never reassigned after initialization. Change these to const to comply with the prefer-const linting rule.

Changes needed
-                    let nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
+                    const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
@@
-                    let nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
+                    const nestedPatchMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
@@
-                    let nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
+                    const nestedDeleteMatch = this.matchUrlPattern(path, UrlPatterns.NESTED_SINGLE);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
const nestedMatch = this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP);
🧰 Tools
🪛 GitHub Actions: Build and Test

[error] 594-594: ESLint: 'nestedMatch' is defined but never reassigned. Use 'const' instead. (prefer-const)

🪛 GitHub Check: build-test (22.x, mysql)

[failure] 594-594:
'nestedMatch' is never reassigned. Use 'const' instead

🪛 GitHub Check: build-test (22.x, postgresql)

[failure] 594-594:
'nestedMatch' is never reassigned. Use 'const' instead

🪛 GitHub Check: build-test (22.x, sqlite)

[failure] 594-594:
'nestedMatch' is never reassigned. Use 'const' instead

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/src/api/rest/index.ts` at line 594, The variable declaration
uses let for values that are never reassigned (e.g., nestedMatch created from
this.matchUrlPattern(path, UrlPatterns.FETCH_RELATIONSHIP)); change these to
const. Update the three occurrences in this module where a value is assigned
from this.matchUrlPattern or similar pattern-matching calls (including the
nestedMatch declaration and the two other let declarations reported) to use
const instead of let so they satisfy the prefer-const lint rule.

Copy link
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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server/src/api/rest/index.ts`:
- Around line 1369-1375: The existence check uses (client as
any)[childType].findFirst with where:
mergeFilters(makeIdFilter(typeInfo.idFields, childId), nestedFilter) but the
subsequent mutation (e.g., (client as any)[childType].update / delete) only uses
the child ID; to prevent TOCTOU, change the mutation's where clause to the same
merged filter (use this.mergeFilters(this.makeIdFilter(typeInfo.idFields,
childId), nestedFilter)) so the update/delete is atomically scoped to the parent
filter (apply the same change to the other occurrence around the update/delete
call referenced in the diff).
- Around line 1434-1442: The current nested DELETE checks membership with
findFirst then calls this.processDelete(client, childType, childId), which
introduces a TOCTOU race; instead delete using a parent-scoped where that
combines the child's id filter and the nestedFilter in a single operation.
Update the call site to pass a merged where (use
this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter))
into the delete operation (or adjust processDelete to accept and use a where
object rather than just childId) so deletion is atomic and scoped to the parent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4e8f8d98-65fb-43b0-8e70-8ee926068b46

📥 Commits

Reviewing files that changed from the base of the PR and between 8c9a4a1 and 45402a3.

📒 Files selected for processing (3)
  • packages/server/src/api/rest/index.ts
  • packages/server/test/api/options-validation.test.ts
  • packages/server/test/api/rest.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/server/test/api/rest.test.ts
  • packages/server/test/api/options-validation.test.ts

@ymc9
Copy link
Member

ymc9 commented Mar 17, 2026

Hi @lsmith77 , thanks for making this PR and my apologies for the late follow up.

I can understand it's problematic that resource mutations cannot be scoped (atomically) to a parent. Since it's not part of JSON:API spec, we'll need to deviate and make an extension.

For the URL design, I'm wondering if, instead of having a set of parallel URL patterns for scoped updates, it's simpler to just extend the current ones. We already have today:

  • :type/:id for single resource
  • :type/:id/:relationship for relationship data
  • :type/id/relationships/:relationship for relationship

Can we just extend the second one to allow:

  1. POST and DELETE operations (currently the URL is read-only)
    POST user/1/posts
    DELETE user/1/profile
  2. A fourth segment for locating one resource in a one-to-many relation, and allow PATCH/DELETE
    PATCH user/1/posts/1
    DELETE user/1/posts/1

Copy link
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

♻️ Duplicate comments (2)
packages/server/src/api/rest/index.ts (2)

1340-1347: ⚠️ Potential issue | 🔴 Critical

Nested DELETE paths still perform unscoped delete after a scoped check.

Both flows verify parent membership first, then delete by child ID only (processDelete). This leaves the same TOCTOU window for concurrent relation changes and can delete outside the requested parent scope.

Also applies to: 1373-1378

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/src/api/rest/index.ts` around lines 1340 - 1347, The nested
DELETE flow currently checks parent membership using (client as
any)[childType].findFirst with mergeFilters(this.makeIdFilter(...),
nestedFilter) but then calls processDelete(childType, childId) which deletes by
ID only and creates a TOCTOU risk; change the deletion to apply the same
combined filter used in the existence check (i.e., perform delete where
mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter))
instead of deleting by ID alone—either update processDelete to accept a filter
object (or an optional nestedFilter parameter) and route the nested-delete path
to call it with the merged filter, or call the client deleteMany on (client as
any)[childType] with the merged filter directly so the check-and-delete is
atomic with respect to the same scope.

1289-1317: ⚠️ Potential issue | 🔴 Critical

Nested PATCH still has a TOCTOU race between membership check and mutation.

Line 1290 scopes the existence check to { childId + parent filter }, but Line 1316 calls processUpdate which updates by child ID only. A concurrent re-parent can still let this nested endpoint update a child no longer under the requested parent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server/src/api/rest/index.ts` around lines 1289 - 1317, The nested
PATCH has a TOCTOU because you check membership with
mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter) but
then call processUpdate(client, childType, childId, ...) which updates by
childId only; fix by making the update atomic/ scoped to the same merged
where-filter: either modify processUpdate to accept a full where filter (e.g.,
mergedWhere = this.mergeFilters(this.makeIdFilter(typeInfo.idFields, childId),
nestedFilter) and pass that instead of childId) or perform an updateMany/update
with that mergedWhere and verify affectedRows > 0 before returning; ensure you
reference childType, childId, nestedFilter, mergeFilters, makeIdFilter and
processUpdate (or use updateMany + check count) so the mutation only succeeds if
the child still belongs to the parent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server/test/api/rest.test.ts`:
- Around line 4026-4033: Update the misleading test comment above the
mappedHandler call: instead of "Wrong parent → 404" describe the asserted
behavior for mappedHandler when using nestedClient (i.e., the request returns
200 with an empty collection). Locate the block using mappedHandler({ method:
'get', path: '/users/u2/posts', client: nestedClient }) and change the comment
to something like "Wrong parent → 200 with empty collection" so it matches the
expect assertions (expect(...status).toBe(200) and
expect(...body.data).toHaveLength(0)).

---

Duplicate comments:
In `@packages/server/src/api/rest/index.ts`:
- Around line 1340-1347: The nested DELETE flow currently checks parent
membership using (client as any)[childType].findFirst with
mergeFilters(this.makeIdFilter(...), nestedFilter) but then calls
processDelete(childType, childId) which deletes by ID only and creates a TOCTOU
risk; change the deletion to apply the same combined filter used in the
existence check (i.e., perform delete where
mergeFilters(this.makeIdFilter(typeInfo.idFields, childId), nestedFilter))
instead of deleting by ID alone—either update processDelete to accept a filter
object (or an optional nestedFilter parameter) and route the nested-delete path
to call it with the merged filter, or call the client deleteMany on (client as
any)[childType] with the merged filter directly so the check-and-delete is
atomic with respect to the same scope.
- Around line 1289-1317: The nested PATCH has a TOCTOU because you check
membership with mergeFilters(this.makeIdFilter(typeInfo.idFields, childId),
nestedFilter) but then call processUpdate(client, childType, childId, ...) which
updates by childId only; fix by making the update atomic/ scoped to the same
merged where-filter: either modify processUpdate to accept a full where filter
(e.g., mergedWhere = this.mergeFilters(this.makeIdFilter(typeInfo.idFields,
childId), nestedFilter) and pass that instead of childId) or perform an
updateMany/update with that mergedWhere and verify affectedRows > 0 before
returning; ensure you reference childType, childId, nestedFilter, mergeFilters,
makeIdFilter and processUpdate (or use updateMany + check count) so the mutation
only succeeds if the child still belongs to the parent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9751e9b4-14be-4e1e-80f8-56d78a3629e9

📥 Commits

Reviewing files that changed from the base of the PR and between 45402a3 and 6ee699d.

📒 Files selected for processing (2)
  • packages/server/src/api/rest/index.ts
  • packages/server/test/api/rest.test.ts

@lsmith77
Copy link
Contributor Author

lsmith77 commented Mar 17, 2026

Thanks for the feedback! I've reworked the implementation to follow your suggestion. The nested route URLs now extend the existing :type/:id/:relationship pattern rather than introducing a parallel set:

  • GET/POST /:type/:id/:relationship — scoped collection read and create (unchanged behavior, same URL)
  • DELETE /:type/:id/:relationship — new: deletes the related resource for a to-one relation (e.g. DELETE user/1/profile)
  • GET/PATCH/DELETE /:type/:id/:relationship/:childId — scoped single-resource operations, replacing the old /:parentType/:parentId/:childType/:childId pattern

The config also got simplified: the second-level key is now the relation field name on the parent (e.g. posts on User) instead of the child model name, and the explicit relation field is dropped since the reverse relation is derived automatically from the schema's opposite field. So { User: { Post: { relation: 'author' } } } becomes { User: { posts: {} } }.

Three things worth flagging before this is finalised:

  1. Config key consistency — the first level of nestedRoutes uses a model name while the second level uses a relation field name, which is a bit inconsistent. An alternative would be to flatten to dotted notation ({ 'User.posts': {} }), which is more uniform and scales better with many parent models. Happy to change this if you'd prefer, but wanted to raise it before altering another public API shape.

  2. Atomicity of updates and deletes — creates are genuinely atomic (the parent connect is part of a single INSERT). DELETEs have been fixed: both DELETE /:type/:id/:relationship/:childId and DELETE /:type/:id/:relationship now use deleteMany with the merged parent+child filter, so the scope check and the write are a single atomic operation. PATCH however still has a TOCTOU: the parent-scope check (findFirst) and the actual write (update) are separate round-trips, because the ORM's update() requires a unique WHERE clause and rejects the merged filter with a 422. Fixing it atomically would require bypassing processUpdate and duplicating its payload-building and serialization logic, which is out of scope for this PR. Leaving this as a known limitation; happy to revisit in a follow-up.

  3. Access control — nested routes add the parent-scope filter on top of whatever ORM client policies are in place. There is no bypass risk, but if no policies are configured, the parent-scope check is the only gate, so applications relying on this feature for access control should be aware that it scopes by parent ownership only, not by the identity of the calling user.

* (e.g., `posts` for `User.posts`). This matches the URL segment used in nested routes:
* `/:parentType/:parentId/:relationName` and `/:parentType/:parentId/:relationName/:childId`.
*/
nestedRoutes?: Record<
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we can make the parent-scoped handling always enabled, and don't require a configuration? What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So we would always offer both the flat and nested API? For me that is fine. Just wonder if someone with super high load would have concerns over the TOCTOU race conditions. But this could be handled by a single global config option.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have pushed a refactoring to use a single boolean flag to enable/disable nestedRoutes

@lsmith77
Copy link
Contributor Author

@ymc9 I have hopefully addressed all of your feedback. The PR now contains 3 commits:

  1. the core functionality
  2. a small refactoring to extract a new common helper for processSingleRead and processNestedSingleRead
  3. a simplification of the configuration introduced in 1) to a single boolean flag

So let me know, if you don't want either 2) or 3), or do not even want any flag to enable/disable this feature and I will update the PR accordingly. Otherwise you can squash merge to get it all into a single commit, depending on your preference.

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.

[Feature Request] Nested routes in the RestApiHandler

2 participants