440 lines
18 KiB
Markdown
440 lines
18 KiB
Markdown
---
|
|
phase: 04-shared-lists-live-sync
|
|
reviewed: 2026-06-09T15:30:00Z
|
|
depth: standard
|
|
files_reviewed: 33
|
|
files_reviewed_list:
|
|
- apps/api/src/db/schema.ts
|
|
- apps/api/src/index.ts
|
|
- apps/api/src/lib/listAccess.ts
|
|
- apps/api/src/lib/listEmitter.ts
|
|
- apps/api/src/lib/rank.ts
|
|
- apps/api/src/routes/lists.ts
|
|
- apps/api/src/routes/sse.ts
|
|
- apps/api/test/setup.ts
|
|
- apps/api/tests/lib/listAccess.test.ts
|
|
- apps/api/tests/lib/listEmitter.test.ts
|
|
- apps/api/tests/lib/rank.test.ts
|
|
- apps/api/tests/routes/lists.test.ts
|
|
- apps/api/vitest.config.ts
|
|
- apps/pwa/src/App.tsx
|
|
- apps/pwa/src/api/listsClient.ts
|
|
- apps/pwa/src/components/AddItemInput.tsx
|
|
- apps/pwa/src/components/AppNav.tsx
|
|
- apps/pwa/src/components/BottomTabBar.tsx
|
|
- apps/pwa/src/components/CalendarShell.test.tsx
|
|
- apps/pwa/src/components/CreateListSheet.tsx
|
|
- apps/pwa/src/components/ItemRow.tsx
|
|
- apps/pwa/src/components/ListCard.tsx
|
|
- apps/pwa/src/components/ListDeleteDialog.tsx
|
|
- apps/pwa/src/components/ListsEmptyState.tsx
|
|
- apps/pwa/src/components/LiveSyncIndicator.tsx
|
|
- apps/pwa/src/hooks/useListSSE.test.ts
|
|
- apps/pwa/src/hooks/useListSSE.ts
|
|
- apps/pwa/src/routes/ListDetail.test.tsx
|
|
- apps/pwa/src/routes/ListDetail.tsx
|
|
- apps/pwa/src/routes/ListsIndex.tsx
|
|
- apps/pwa/src/store/listsStore.ts
|
|
- apps/pwa/package.json
|
|
- apps/api/package.json
|
|
findings:
|
|
critical: 3
|
|
warning: 5
|
|
info: 3
|
|
total: 11
|
|
status: 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:
|
|
|
|
1. Write `is_shared = false` to the `lists` row (changing the list's visibility state on
|
|
behalf of the owner without consent).
|
|
2. Delete ALL rows from `list_shares` for 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):
|
|
|
|
```typescript
|
|
// 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/abc` skips the `checkListAccess` notFound→404
|
|
branch and returns a 404, which is benign but by accident.
|
|
- A crafted request to `GET /api/lists/abc/items` proceeds past the access check with
|
|
`listId = 0`, queries `WHERE list_id = 0` (no rows), and returns `{ items: [] }` — a
|
|
200 with empty data rather than a 400.
|
|
- The `listItemsRouter` `PATCH /:itemId` at line 569 fetches `WHERE id = 0` from
|
|
`list_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:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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_
|