Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,5 @@ That starts:
- [docs/status.md](docs/status.md) — current gaps and next improvement areas
- [docs/auth-plan.md](docs/auth-plan.md) — current auth status and constraints
- [docs/review-auth-system.md](docs/review-auth-system.md) — dated auth-system code-review snapshot
- [docs/review-2026-04-10.md](docs/review-2026-04-10.md) — dated full-repo review + changelog (most recent)
- [docs/review-2026-04-11.md](docs/review-2026-04-11.md) — dated full-repo review + changelog (most recent)
- [docs/review-2026-04-10.md](docs/review-2026-04-10.md) — previous dated full-repo review + changelog
20 changes: 20 additions & 0 deletions apps/web/src/components/TranscriptCard.speaker-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,24 @@ describe("TranscriptCard speaker selection", () => {
expect(screen.getByText("No speakers selected.")).toBeTruthy();
expect(screen.queryByText("Guest reply.")).toBeNull();
});

it("exposes aria-pressed state on each speaker-filter chip and flips it on toggle", () => {
const view = renderTranscriptCard();

const chips = view.container.querySelectorAll<HTMLButtonElement>("button.speaker-filter-chip");
expect(chips.length).toBe(2);
// Both chips start selected (aria-pressed=true).
chips.forEach((chip) => {
expect(chip.getAttribute("aria-pressed")).toBe("true");
});

fireEvent.click(chips[0]!);

// After toggling Host off, the first chip flips to aria-pressed=false; the
// second chip stays pressed. aria-label also flips to match.
expect(chips[0]!.getAttribute("aria-pressed")).toBe("false");
expect(chips[0]!.getAttribute("aria-label")).toBe("Show Host");
expect(chips[1]!.getAttribute("aria-pressed")).toBe("true");
expect(chips[1]!.getAttribute("aria-label")).toBe("Hide Guest");
});
});
85 changes: 85 additions & 0 deletions apps/web/src/components/player-card/playbackFilter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,89 @@ describe("playbackFilter", () => {

expect(getPlaybackCorrection(0, ranges)).toBe(5);
});

it("returns no ranges when durationSeconds is non-positive", () => {
const ranges = buildPlayableSpeakerRanges({
durationSeconds: 0,
transcriptSegments: [
{ startSeconds: 1, endSeconds: 2, speaker: 0 },
],
selectedSpeakerIds: new Set([0]),
speakerFilteringActive: true,
});
expect(ranges).toEqual([]);
});

it("clamps segment boundaries to [0, durationSeconds]", () => {
const ranges = buildPlayableSpeakerRanges({
durationSeconds: 10,
transcriptSegments: [
// Start before 0 and end after duration — should clamp, not drop.
{ startSeconds: -2, endSeconds: 100, speaker: 0 },
],
selectedSpeakerIds: new Set([0]),
speakerFilteringActive: true,
segmentStartPaddingSeconds: 0,
segmentEndPaddingSeconds: 0,
segmentMergeGapSeconds: 0,
});
expect(ranges).toEqual([{ startSeconds: 0, endSeconds: 10 }]);
});

it("sorts input segments by start time before merging", () => {
// Out-of-order input simulates a transcript that arrived with reordered
// segments (e.g. after an edit). buildPlayableSpeakerRanges must normalize.
const ranges = buildPlayableSpeakerRanges({
durationSeconds: 30,
transcriptSegments: [
{ startSeconds: 10, endSeconds: 12, speaker: 0 },
{ startSeconds: 4, endSeconds: 6, speaker: 0 },
{ startSeconds: 6.05, endSeconds: 8, speaker: 0 },
],
selectedSpeakerIds: new Set([0]),
speakerFilteringActive: true,
segmentStartPaddingSeconds: 0,
segmentEndPaddingSeconds: 0,
segmentMergeGapSeconds: 0.1,
});
expect(ranges).toEqual([
{ startSeconds: 4, endSeconds: 8 },
{ startSeconds: 10, endSeconds: 12 },
]);
});

it("drops non-finite or degenerate segments without crashing", () => {
const ranges = buildPlayableSpeakerRanges({
durationSeconds: 30,
transcriptSegments: [
{ startSeconds: Number.NaN, endSeconds: 3, speaker: 0 },
{ startSeconds: 5, endSeconds: 5, speaker: 0 }, // zero-length collapses after padding
{ startSeconds: 10, endSeconds: 12, speaker: 0 },
],
selectedSpeakerIds: new Set([0]),
speakerFilteringActive: true,
segmentStartPaddingSeconds: 0,
segmentEndPaddingSeconds: 0,
segmentMergeGapSeconds: 0,
});
expect(ranges).toEqual([{ startSeconds: 10, endSeconds: 12 }]);
});

it("passes through every segment when speakerFilteringActive is false", () => {
const ranges = buildPlayableSpeakerRanges({
durationSeconds: 30,
transcriptSegments: [
{ startSeconds: 0, endSeconds: 2, speaker: 0 },
{ startSeconds: 2, endSeconds: 4, speaker: 1 },
],
// Empty selection would normally hide everything, but filtering is off
// so everything is playable.
selectedSpeakerIds: new Set<number>(),
speakerFilteringActive: false,
segmentStartPaddingSeconds: 0,
segmentEndPaddingSeconds: 0,
segmentMergeGapSeconds: 0,
});
expect(ranges).toEqual([{ startSeconds: 0, endSeconds: 4 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,15 @@ export function TranscriptControls({
)}
{speakerIds.map((speaker) => {
const isHidden = hiddenSpeakers.has(speaker);
const label = getSpeakerLabel(speaker);
return (
<button
key={`speaker-filter-${speaker}`}
type="button"
// Speaker-filter chips are toggle buttons — exposing aria-pressed
// lets screen readers announce visibility state correctly.
aria-pressed={!isHidden}
aria-label={isHidden ? `Show ${label}` : `Hide ${label}`}
onClick={() => onToggleSpeakerVisibility(speaker)}
className={`speaker-filter-chip ${isHidden ? "speaker-filter-chip-hidden" : ""}`}
style={{
Expand All @@ -211,7 +216,7 @@ export function TranscriptControls({
}}
title={isHidden ? "Show speaker" : "Hide speaker"}
>
{getSpeakerLabel(speaker)}
{label}
</button>
);
})}
Expand Down
1 change: 1 addition & 0 deletions docs/cap5_implementation_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ In progress.
- tests for cleanup-artifact key collection and no-object path
- tests for `claimOne()` + `reclaimExpiredLeases()` (parameters, SQL path, batch-size env)
- `WORKER_CLAIM_BATCH_SIZE` is now explicitly reserved/dormant; reclaim was split out to a dedicated `WORKER_RECLAIM_BATCH_SIZE`
- edge-case unit coverage for `buildPlayableSpeakerRanges` (duration guard, boundary clamp, out-of-order inputs, non-finite/degenerate segments, filter-off passthrough)

### Remaining work
1. expand delete + retry lifecycle coverage beyond focused unit tests
Expand Down
13 changes: 11 additions & 2 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ Response:

Behavior:
- succeeds only if zero users exist in the database
- returns 400 if attempted when a user already exists
- returns 201 on success with `{ ok: true, userId }`
- returns 409 if attempted when a user already exists
- hashes password with bcrypt (cost 12)

### `POST /api/auth/login`
Expand All @@ -76,6 +77,7 @@ Response:

Behavior:
- returns 401 on invalid email or password
- returns 429 with a `Retry-After` header when throttled by login rate limiting
- sets `cap5_token` as an httpOnly, Secure, SameSite=Strict cookie
- token is a JWT signed with HS256, valid for 7 days by default

Expand Down Expand Up @@ -108,7 +110,7 @@ Response:
Behavior:
- requires valid JWT in `Authorization: Bearer <token>` header or `cap5_token` cookie
- returns 401 if not authenticated
- returns 500 if user record no longer exists (shouldn't happen in normal operation)
- returns 404 if the user record no longer exists (shouldn't happen in normal operation)

### Authentication headers

Expand Down Expand Up @@ -214,11 +216,18 @@ Behavior:
### `POST /api/videos/:id/delete`
Soft delete.

Response:

```json
{ "ok": true, "videoId": "uuid", "deletedAt": "2026-04-11T00:00:00Z" }
```

Behavior:

- requires `Idempotency-Key`
- sets `videos.deleted_at`
- queues `cleanup_artifacts` delayed by 5 minutes
- idempotent: calling on an already-deleted video returns the original `deletedAt` without re-queueing cleanup

### `POST /api/videos/:id/retry`
Retry path for transcription / AI work.
Expand Down
107 changes: 107 additions & 0 deletions docs/review-2026-04-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# cap5 — full-repo review and changelog

**Date:** 2026-04-11
**Scope:** Follow-up audit after the 2026-04-10 review. Doc-vs-code drift, small accessibility fix on the speaker filter, expanded unit coverage around selected-speaker playback, and an inventory of on-disk cruft that still needs host-side cleanup.
**Verification:** `pnpm typecheck` + `pnpm test` all green after changes. The new tests run locally in `apps/web`.

This file is a point-in-time record. For ongoing status, see `docs/status.md`; for sequencing, see `docs/cap5_implementation_plan.md`; for the previous review, see `docs/review-2026-04-10.md`.

## Verdict

The repo is still in materially good shape. No critical bugs, no security-incident-class issues. The previous review's big structural edits (Docker migrate switched to the Node runner, `WORKER_CLAIM_BATCH_SIZE` split into claim + reclaim, `formatDuration`/`formatTimestamp` dedupe, S3 endpoint defaults aligned, claim/reclaim test coverage, Web E2E ARIA-tab alignment) are all still in place and correct. The speaker-filter playback feature that landed in the last commit on `chore/review-2026-04-10` (`1729942 feat(web): ship speaker-filter playback impl that test 9 depends on`) is coherent and the code matches the docs it produced.

What this pass found were the smaller things left behind by a busy merge week: three doc claims that disagreed with the live code, one ARIA gap on the new speaker-filter chips, and five-ish playbackFilter edge cases worth locking in before anyone else refactors that module.

## Changes applied in this review

### 1. `docs/system.md` — "signed outbound webhooks" removed from "Intentionally missing"

`docs/system.md` listed "signed outbound webhooks" in the *Intentionally missing or incomplete* section at the bottom of the file. That has not been true since outbound HMAC signing shipped — ADR-003 in the same file documents the inbound/outbound HMAC shape; `apps/worker/src/lib/hmac.ts::signOutboundWebhook` is the live implementation; `apps/worker/src/handlers/deliver-webhook.ts` uses it; `README.md` lists "outbound notification webhooks … with HMAC headers" under *What works now*; and `docs/cap5_implementation_plan.md` lists signed outbound webhook delivery as Phase 1 / completed. Four places said shipped, one place said missing. The missing-one was wrong; removed.

### 2. `docs/contracts.md` — auth status codes corrected to match `apps/web-api/src/routes/auth.ts`

Three small corrections, all verified against the live route handler:

- `POST /api/auth/setup` — doc said "returns 400 if attempted when a user already exists". Code returns **409** (`reply.code(409).send({ error: 'Account already exists' })` at `auth.ts:80`). Also added the missing success shape `{ ok: true, userId }` with explicit 201 status (`auth.ts:97`).
- `POST /api/auth/login` — added the 429 branch. Login has rate limiting via `InMemoryLoginRateLimiter` and returns 429 with a `Retry-After` header when throttled (`auth.ts:119`). This was live but undocumented in the contracts file, so anyone writing a client would treat 429 as unexpected.
- `GET /api/auth/me` — doc said "returns 500 if user record no longer exists". Code returns **404** (`auth.ts:195`).

### 3. `docs/contracts.md` — `POST /api/videos/:id/delete` response shape made explicit

The delete contract described behavior but did not show the response shape. Added the concrete `{ ok, videoId, deletedAt }` response and called out that calling delete on an already-deleted video is idempotent — it returns the original `deletedAt` without re-queueing `cleanup_artifacts`. That idempotent path is important for anyone building a retry-happy client because otherwise they'd see cleanup jobs accumulating on repeated delete calls (they don't).

### 4. Accessibility: `aria-pressed` on speaker-filter chips

`apps/web/src/components/transcript-card/TranscriptControls.tsx` renders the speaker-filter chips as plain `<button>` elements. They are visually toggle buttons — click to hide a speaker, click again to show — but they did not expose toggle state to assistive tech. This is the same class of gap the 2026-04-10 review already fixed for the video-rail tabs (`role="tab"` + `aria-selected`). Now each chip carries:

- `aria-pressed={!isHidden}` — screen readers announce "pressed" / "not pressed" correctly as speakers are toggled
- `aria-label` that flips between `"Hide {label}"` and `"Show {label}"` so the announced name describes the action, not just the speaker name

Neither the Vitest component test (`TranscriptCard.speaker-selection.test.tsx`, which locates chips by `button.speaker-filter-chip`) nor the Playwright test (`player.spec.ts`, which filters by `hasText: "Guest"`) depends on the absence of these attributes, so this is an additive a11y improvement.

### 5. New test coverage — `TranscriptCard.speaker-selection.test.tsx`

Added one test that asserts the new `aria-pressed` / `aria-label` behavior on the chips, including that clicking one chip flips *only* that chip's pressed state and label. This locks in the a11y contract so a future refactor can't silently regress it.

Total in this file: 3 → 4 tests.

### 6. New test coverage — `apps/web/src/components/player-card/playbackFilter.test.ts`

The playbackFilter module is pure logic, easy to test, and directly on the critical path of the selected-speaker-sequence feature. The existing five tests covered the happy paths (merge, skip, unlabeled passthrough, all-deselected, pre-seek correction) but not the awkward inputs that would be easy to introduce via a data shape change. Added five more:

- `returns no ranges when durationSeconds is non-positive` — guards the early exit
- `clamps segment boundaries to [0, durationSeconds]` — segments that extend past the end of the video (e.g. from a mislabeled transcript) must not produce ranges outside the playable window
- `sorts input segments by start time before merging` — if the caller hands in out-of-order segments (e.g. post-edit), the merge step still produces the correct non-overlapping ranges
- `drops non-finite or degenerate segments without crashing` — NaN starts, zero-length segments after padding, etc. are filtered, not treated as playable
- `passes through every segment when speakerFilteringActive is false` — the "filter turned off" branch is respected even with an empty `selectedSpeakerIds` set (which would otherwise hide everything)

Total in this file: 5 → 10 tests.

## Findings raised and explicitly *rejected* after ground-truthing

- **"The delete endpoint isn't idempotent because it always queues cleanup."** — False. `apps/web-api/src/routes/videos.ts:490` guards `INSERT INTO job_queue … 'cleanup_artifacts'` behind `if (!deletedAt)`, so a repeat delete call returns the original `deletedAt` and does *not* re-queue the cleanup job. The contracts doc just didn't document this clearly; fixed in change #3.
- **"`aria-pressed` on speaker-filter-chip breaks the existing tests."** — False. Both the Vitest component test and the Playwright E2E test locate chips via `button.speaker-filter-chip` (CSS) or `hasText: "Guest"` (visible text). Neither depends on the chip being a plain button without ARIA.
- **"`apps/worker/dist/queue/claim.js` still reads `WORKER_CLAIM_BATCH_SIZE`."** — Confirmed stale build artifact. But `dist/` is in `.gitignore` (`apps/worker/dist/queue/claim.js` is not tracked). It will be regenerated correctly from `src/queue/claim.ts` (which already reads `WORKER_RECLAIM_BATCH_SIZE`) the next time anyone runs `pnpm build`. Not a repo issue, not a fix.
- **"`BLOCKED_WEBHOOK_HOSTS` is incomplete — it misses `169.254.169.254` (cloud metadata) and link-local IPv6."** — Technically true but out of scope for this audit and already flagged in the contracts doc's *Security-sensitive rules to remember* section: "create-video `webhookUrl` validation blocks some obvious internal/local targets, but this should not be treated as a complete outbound request policy." The 2026-04-10 review already lists SSRF-posture follow-up as a pending item under Phase 1. Leaving it for that dedicated pass.
- **"The `setupRequired` check uses `count === 0` without a transaction — race condition."** — Theoretically yes, realistically no. Setup is a one-shot bootstrap path, and the `INSERT INTO users` has a unique-email constraint. If two setup calls land simultaneously one will fail the insert; the `count > 0` check just returns a nicer 409 on the common second-call case.

## Cruft found but *not* removed (file-deletion permission blocked in this session)

Same category as the 2026-04-10 review: on-disk noise that doesn't affect correctness but is worth a cleanup pass. The shipping script in `scripts/ship-review-2026-04-11.sh` removes all of these from a local shell, since this session could edit files but could not delete them.

1. **`nanobanana/`** — still ~371 MB on disk. Already in `.gitignore`. Not referenced by any shipping code path. `rm -rf nanobanana`.
2. **`.DS_Store` files** — 21 on disk now (up from 18 on 2026-04-10), none tracked. `find . -name .DS_Store -delete`.
3. **`apps/web/vite.config.ts.timestamp-*.mjs`** — 2 ephemeral vite build stamps left from an interrupted dev run. Not tracked. Safe to delete.
4. **`scripts/fix-ci-lint.sh`, `scripts/ship-review-2026-04-10.sh`** — leftover shipping scripts from the 2026-04-10 pass; not tracked. The ship script for *this* pass (`scripts/ship-review-2026-04-11.sh`) removes them as part of its cleanup.
5. **`apps/web/src/components/ChapterList.tsx`** — still imported only by its own test file. Same judgment call as last time: if you want it removed, delete both `ChapterList.tsx` and `apps/web/src/__tests__/ChapterList.test.tsx` together, since the 10 tests encode a legitimate spec for an otherwise-orphaned component.
6. **Local-only branch `feat/auth-and-code-quality`** — still present locally after PR #3 merged. `git branch -D feat/auth-and-code-quality` if unneeded.

## Verification

Applied via the Edit tool to the workspace; run locally to confirm green (the sandbox could not run pnpm directly this session):

- `pnpm --filter @cap/web typecheck`
- `pnpm --filter @cap/web test` — expected 20/20+ (was 19, +1 new speaker-selection chip test, +5 new playbackFilter edge tests = 25)
- `pnpm --filter @cap/web test:e2e` — unchanged, no selector changes to break anything
- `pnpm typecheck` — unchanged scope, still green

The ship script runs the full `pnpm typecheck` and `pnpm --filter @cap/web test` locally before committing, so the first round of verification happens on Murry's machine as part of the ship flow.

## Files changed

Edited:
```
docs/system.md
docs/contracts.md
apps/web/src/components/transcript-card/TranscriptControls.tsx
apps/web/src/components/TranscriptCard.speaker-selection.test.tsx
apps/web/src/components/player-card/playbackFilter.test.ts
docs/status.md
docs/cap5_implementation_plan.md
```

Added:
```
docs/review-2026-04-11.md (new — this file)
scripts/ship-review-2026-04-11.sh (new — local commit+push+cleanup helper)
```
2 changes: 2 additions & 0 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Automated coverage exists for:
- worker tests for queue failure transitions and cleanup lifecycle
- worker tests for `claimOne()` and `reclaimExpiredLeases()` (parameters, SQL path, batch-size env)
- API unit tests for login throttling behavior
- web unit tests for speaker-filter chip `aria-pressed` toggle state
- web unit tests for `buildPlayableSpeakerRanges` edge cases (non-positive duration, boundary clamping, out-of-order input, non-finite/degenerate segments, filter-off passthrough)

Highest-risk gaps:

Expand Down
1 change: 0 additions & 1 deletion docs/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,5 @@ Pino-based structured JSON logging. Redacts: password, secret, token, apiKey, au
## Intentionally missing or incomplete

- multi-tenant isolation
- signed outbound webhooks
- active HLS processing path
- polished production deployment story beyond Docker Compose
Loading
Loading