Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
18 KiB
phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status
| phase | reviewed | depth | files_reviewed | files_reviewed_list | findings | status | |||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-shared-lists-live-sync | 2026-06-09T15:30:00Z | standard | 33 |
|
|
issues_found |
Phase 4: Code Review Report
Reviewed: 2026-06-09T15:30:00Z Depth: standard Files Reviewed: 33 Status: issues_found
Summary
Reviewed the full Phase 4 shared-lists + live-sync implementation: API routes, schema, access-control helpers, SSE fan-out, fractional rank, and the React PWA layer (mutations, SSE hook, drag-to-reorder, components). The previously-recorded rank collation bug (uppercase fractional-indexing keys sort incorrectly under utf8mb4_uca1400_ai_ci) is acknowledged but not re-litigated here per brief instructions.
Three critical issues were found: a privilege-escalation hole that lets any list sharee
unilaterally de-share or re-share a list (purging or creating list_shares rows for ALL
household members), an SSE subscription scope that is computed once at connect time and
never refreshed (so a newly-shared list never reaches a live subscriber without a
disconnect/reconnect), and a missing NaN-guard on URL path parameters that causes DB
queries to execute with a filter of id = NaN instead of returning 400.
Critical Issues
CR-01: Sharee can de-share or re-share a list — privilege escalation on isShared toggle
File: apps/api/src/routes/lists.ts:319-393
Issue: PATCH /api/lists/:id gates on checkListAccess (owner OR sharee) but does
not restrict the isShared field to the owner. A sharee — any household member who was
granted access — can send { isShared: false } and the handler will:
- Write
is_shared = falseto thelistsrow (changing the list's visibility state on behalf of the owner without consent). - Delete ALL rows from
list_sharesfor that list (line 366-368), immediately revoking every other member's access including the owner's own sharee visibility.
The inverse (a sharee escalating a private list to shared by sending { isShared: true })
is also possible, inserting list_shares rows for every user in the DB without the owner's
consent.
The comment on line 344 reads "Reconcile list_shares on visibility change (owner only
affects shares)" but there is no isOwner guard anywhere in the PATCH handler — the
reconciliation runs unconditionally for any allowed user.
The test at lists.test.ts:438-450 explicitly tests and asserts that a sharee CAN rename
a list, which is correct, but there is no test asserting that a sharee CANNOT toggle
isShared. The gap is uncovered.
Fix: Add an owner-only guard before the isShared reconciliation block (and before
writing isShared itself, since the DB field controls visibility semantics):
// In PATCH /:id, after the access check at line 327-332:
if (patch.isShared !== undefined && !access.isOwner) {
return c.json({ error: 'Only the list owner can change sharing settings' }, 403);
}
CR-02: SSE subscription scope is stale — newly shared lists never delivered to live subscribers
File: apps/api/src/routes/sse.ts:85-121
Issue: GET /api/sse/lists calls getAccessibleListIds(userId) exactly once at
connection time (line 89), then subscribes only to those list channels. If the user's
access set changes while the SSE connection is open — for example, another member creates
a new shared list (which inserts a list_shares row for this user), or a PATCH toggles
isShared — the live subscriber never receives list:updated events for the new list
because no subscription was registered for its channel.
From the client's perspective: member A creates "Groceries" (shared). The publishListEvent
fires on channel list:${newId}. Member B's open SSE stream has no subscriber on that
channel — it was computed before the list existed. B only learns about the list when the
30-second polling fallback fires (D-12).
This means the "other member sees the change appear without refreshing" requirement (Truth 3) is not met for newly-created shared lists while both members are simultaneously connected. The 30-second polling fallback (D-12) masks the failure but does not eliminate it.
Fix (two options):
Option A (minimal): When POST /api/lists creates a shared list, publish a special
list:created event to a well-known global channel (e.g. global:lists) that all
authenticated SSE connections also subscribe to. On receiving list:created, the client
invalidates ['lists'] and re-establishes (or the server issues a reconnect hint).
Option B (structural, recommended): Store the SSE handler's userId and wire the
list:created event through a per-user "inbox" channel (user:${userId}) that the SSE
endpoint subscribes to in addition to the per-list channels. POST /api/lists fans out
to each sharee's inbox. The SSE handler then dynamically adds a new per-list subscription
when it receives the inbox event.
At minimum, POST /api/lists, PATCH /api/lists/:id (when toggling isShared), and
the list:deleted flow all need to trigger re-subscription updates for affected users.
CR-03: Number(c.req.param(...)) — NaN propagates silently into DB queries
File: apps/api/src/routes/lists.ts:323, 407, 459, 521, 569, 663
Issue: Every route that reads a URL path parameter converts it with bare Number(...).
Number('abc') is NaN. All subsequent Drizzle eq(lists.id, NaN) calls emit SQL like
WHERE id = NaN which MariaDB coerces to WHERE id = 0. This returns "not found" for
most paths, but the behavior is implementation-defined and fragile:
- A crafted request to
PATCH /api/lists/abcskips thecheckListAccessnotFound→404 branch and returns a 404, which is benign but by accident. - A crafted request to
GET /api/lists/abc/itemsproceeds past the access check withlistId = 0, queriesWHERE list_id = 0(no rows), and returns{ items: [] }— a 200 with empty data rather than a 400. - The
listItemsRouterPATCH /:itemIdat line 569 fetchesWHERE id = 0fromlist_items, gets no row, and returns 404, which again masks rather than rejects.
Silently treating invalid input as a DB query is incorrect behavior. Every route should validate the path parameter before touching the DB.
Fix: Add NaN validation immediately after each Number(...) conversion:
const listId = Number(c.req.param('id'));
if (!Number.isInteger(listId) || listId < 1) {
return c.json({ error: 'Invalid id' }, 400);
}
Apply the same pattern to itemId at lines 569 and 663. ListDetail.tsx already does
this check for its own parsedListId (line 315), confirming the pattern is known; it
just was not applied server-side.
Warnings
WR-01: SSE listener registered as async but errors inside it are silently dropped
File: apps/api/src/routes/sse.ts:96-103
Issue: The handler passed to subscribeListEvents is declared async:
const unsub = subscribeListEvents(listId, async (event) => {
if (stream.aborted) return
await stream.writeSSE(...)
})
EventEmitter.emit() does not await Promises returned by listeners. If stream.writeSSE
rejects (e.g. the underlying socket was half-closed but stream.aborted has not been set
yet), the rejection is an unhandled Promise rejection. Under Node.js 18+ this can crash
the process depending on the unhandledRejection policy. In production behind Pangolin the
risk is a silent dropped write followed by an eventual crash.
Fix: Wrap the async body in a try/catch:
subscribeListEvents(listId, (event) => {
if (stream.aborted) return;
stream
.writeSSE({
data: JSON.stringify(event),
event: event.type,
id: `${listId}-${Date.now()}`,
})
.catch((err) => {
console.error('[sse/lists] writeSSE failed:', err);
});
});
WR-02: Unsubscribers run AFTER the heartbeat loop exits — they may never run if writeSSE throws
File: apps/api/src/routes/sse.ts:107-119
Issue: The cleanup block (unsubscribers.forEach(...) at line 119) is placed after the
while (!stream.aborted) loop. If stream.writeSSE inside the heartbeat loop throws
synchronously, the loop exits via exception propagation and the unsubscribers.forEach
line is never reached. This leaves orphaned listeners attached to the module-level emitter
for the lifetime of the process — a listener leak that accumulates with every aborted
connection.
In the current implementation streamSSE from Hono likely catches the inner Promise, but
the placement creates a fragile dependency on that behavior.
Fix: Use a try/finally block to guarantee cleanup:
return streamSSE(c, async (stream) => {
const unsubscribers: Array<() => void> = []
try {
for (const listId of accessibleListIds) {
const unsub = subscribeListEvents(listId, (event) => { ... })
unsubscribers.push(unsub)
}
let tick = 0
while (!stream.aborted) {
await stream.writeSSE({ ... })
await stream.sleep(30_000)
}
} finally {
unsubscribers.forEach((unsub) => unsub())
}
})
WR-03: position field in patchItemSchema accepts any string — no fractional-indexing format validation
File: apps/api/src/routes/lists.ts:107-117
Issue: patchItemSchema validates position as z.string().min(1).max(255). A client
can send any arbitrary string as a rank (e.g. "aaaaa..." 255 chars, or "\x00").
fractional-indexing has specific format constraints: keys must match a particular
character set and structure. An invalid rank value written to the DB will permanently
corrupt the ordering for all items in the list, since subsequent generateKeyBetween
calls against a malformed neighbor will throw or produce unpredictable output.
This is particularly relevant because malformed ranks survive server-side silently — the
DB stores whatever string is written and returns it in ORDER BY, but generateKeyBetween
on the PWA side will throw when encountering an out-of-spec rank as a neighbor.
Fix: Add a regex validator matching the fractional-indexing key format. The library
produces keys in [A-Za-z0-9] with specific leading-character rules. At minimum, restrict
to the documented safe character set:
position: z.string()
.min(1)
.max(255)
.regex(/^[A-Za-z0-9]+$/, 'Invalid fractional rank format'),
Or call validateOrderKey from the fractional-indexing package inside a .refine().
WR-04: PATCH /api/lists/:id does not guard isShared changes against non-owner callers in test coverage
File: apps/api/tests/routes/lists.test.ts:438-450
Issue: The test "sharee can rename a shared list they have access to" asserts the
correct behaviour (sharees can rename), but there is no corresponding negative test
asserting that a sharee CANNOT change isShared. Given CR-01 above is a confirmed bug,
the absence of this test means the regression will go undetected after the fix unless a
test is added simultaneously.
Fix: Add a test case in the PATCH /api/lists/:id describe block:
it('returns 403 when a sharee attempts to change isShared (owner-only)', async () => {
const ownerId = await seedUser('patch-isshared-owner');
const shareeId = await seedUser('patch-isshared-sharee');
const listId = await seedList(ownerId, 'Shared List', true);
await shareList(listId, shareeId);
currentDevUserId = shareeId;
const app = await getApp();
const res = await app.request(jsonRequest('PATCH', `/api/lists/${listId}`, { isShared: false }));
expect(res.status).toBe(403);
});
WR-05: Optimistic rank computation in addMutation can produce a duplicate rank when a concurrent add is in-flight
File: apps/pwa/src/routes/ListDetail.tsx:159-162
Issue: addMutation.onMutate computes the optimistic rank using:
const lastRank = activeItems.at(-1)?.rank ?? null;
const optimisticRank = generateKeyBetween(lastRank, null);
activeItems is the locally-computed split of the React Query cache at the time the
mutation fires. If two concurrent adds are initiated in quick succession (e.g. rapid Enter
key taps), the second onMutate reads the cache that already contains the first optimistic
item (with id: -Date.now()). However, the first optimistic item's rank was computed from
the same lastRank, so generateKeyBetween(lastRank, null) is called twice with the
same lastRank, producing the same rank string for both optimistic items.
Both items render visually without issue, but on settlement the first item gets rank R1
from the server and the second gets rank R2 > R1. The transient duplicate rank in the cache
can cause a visible re-ordering flash during the onSettled invalidation.
This is a cosmetic issue only (server round-trips produce correct ordering), but it violates the "no accidental reorder flash" UX expectation.
Fix: After the first optimistic insert, re-read the cache to get the updated last rank
for the second add. Since onMutate is async, read the updated cache state after
cancelQueries completes:
onMutate: async (text: string) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
// Read AFTER cancel so concurrent in-flight optimistic updates are visible
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
const currentActiveItems = (previous?.items ?? [])
.filter((i) => !i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : 1))
const lastRank = currentActiveItems.at(-1)?.rank ?? null
...
}
Info
IN-01: useListSSE connects to /api/sse/lists — not scoped to the current listId
File: apps/pwa/src/hooks/useListSSE.ts:65
Issue: The hook is parameterized on listId and invalidates ['list', listId] on
events, but the SSE connection it opens is /api/sse/lists — the server-side global
fan-out stream for ALL lists the user can access. Events for other lists the user owns or
shares (e.g. a grocery list while viewing a gift list) also trigger handleListChange,
which only invalidates the currently-viewed list's query key. Events for other lists are
received and ignored, which is harmless but slightly wasteful.
The listId parameter to the hook is used only for cache invalidation, not for scoping
the server subscription. This is by design per D-10, but the hook's name (useListSSE)
and the listId parameter imply it is scoped to one list, which may confuse future
maintainers.
Fix (documentation): Add a comment clarifying that the connection is intentionally
global and listId is only the invalidation target. Alternatively, rename the parameter
to activeListId to signal its limited scope.
IN-02: getAccessibleListIds issues two sequential DB round-trips that could be one query
File: apps/api/src/lib/listAccess.ts:29-43
Issue: The function issues two separate SELECT queries — one for owned lists, one
for shared lists — then unions the results in JavaScript. This is two DB round-trips
where one UNION or a single query with an OR would suffice. In a two-member household
the cost is negligible; it is called at SSE connection time and can be called on every
request to GET /api/lists in the future. As the call count grows this becomes a latency
doubling point.
Fix: Not urgent, but a single query avoids the double round-trip:
// Single query with OR
const rows = await db
.selectDistinct({ id: lists.id })
.from(lists)
.leftJoin(listShares, eq(listShares.listId, lists.id))
.where(or(eq(lists.ownerId, userId), eq(listShares.userId, userId)));
return rows.map((r) => r.id);
IN-03: The 401 test in lists.test.ts is a no-op assertion
File: apps/api/tests/routes/lists.test.ts:263-287
Issue: The test "returns 401 when no session is set" contains the assertion
expect(true).toBe(true) with a comment explaining why the 401 path is not actually
exercised. The test body documents a known gap in test coverage (the dev-bypass path makes
it impossible to test 401 via the same app instance without module-level re-mocking). This
is a real gap — the 401 enforcement path is never exercised in the automated suite.
The test gives false confidence by appearing in the describe block as a passing test while asserting nothing about the code under review.
Fix: Either remove the test (if it cannot be implemented), or implement it properly by
using vi.doMock before a fresh import() of app to override devAuthBypass to a
no-op in that test only, then assert res.status === 401. The pattern is already used in
the @hono/oidc-auth mock above it. Keeping a passing test that asserts true === true
is misleading.
Reviewed: 2026-06-09T15:30:00Z Reviewer: Claude (gsd-code-reviewer) Depth: standard