docs(20-08): call and session fixes summary
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 20-demo-instance-with-every-shared-plugin
|
||||
plan: 08
|
||||
subsystem: chat-video-frontend
|
||||
tags: [vue, nuxt, livekit, vitest, chat, video, auth, gap-closure]
|
||||
status: complete
|
||||
gap_closure: true
|
||||
review_findings: [CR-01, WR-01, WR-04, WR-05, CR-02]
|
||||
|
||||
requires:
|
||||
- phase: 20-06
|
||||
provides: "useCall on a fake Room, the chat layer's tests, the community auth hooks and their tests"
|
||||
provides:
|
||||
- "useCall: a per-attempt token with stale checks after every await; leave works and is final at any moment"
|
||||
- "useCall: a failed connect (or livekit-client import) ends a session nobody else joined (GET status)"
|
||||
- "videoClient: hasStart, rememberAccept, takeAccept, callAutostart (click handoffs)"
|
||||
- "ChatvideoCall: confirmation (chatvideo-confirm, chatvideo-confirm__go) unless the call button or Accept just handed over"
|
||||
- "vue-chat-layer: honestHttpUrl (app/lib/urlGuard.ts), used by safeUrl, linkify and messageUrls"
|
||||
- "vue-demo-app: createCommunitySignOut and endSession; logout and unauthorized() reset the chat, close the socket and dismiss the ring"
|
||||
affects: [20-09]
|
||||
|
||||
actuals:
|
||||
tokens: 16745 # chars/4 over the three repos' diffs (8222255..HEAD, b6d3389..HEAD, deb809f..HEAD)
|
||||
tasks: 5
|
||||
commits: 5 # measured per repo: vue-chatvideo-layer 3, vue-chat-layer 1, vue-demo-app 1; none in summercms.go
|
||||
plan_head_before: "vue-chatvideo-layer 8222255, vue-chat-layer b6d3389, vue-demo-app deb809f"
|
||||
plan_head_after: "vue-chatvideo-layer 5386903, vue-chat-layer a798bb1, vue-demo-app 188ae25"
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Per-attempt object ({start, answered, left}) plus a stale() closure checked after every await of an async lifecycle"
|
||||
- "In-memory click handoffs (a minute, taken once) as proof of user intent before media starts"
|
||||
- "A written-host-equals-opened-host guard before any anchor or link card is produced"
|
||||
- "A Nuxt-free sign-out factory whose parts run isolated, wired by the plugin with runWithContext"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ../vue-chat-layer/app/lib/urlGuard.ts
|
||||
- ../vue-chat-layer/app/lib/urlGuard.test.ts
|
||||
modified:
|
||||
- ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- ../vue-chatvideo-layer/app/lib/videoClient.ts
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
|
||||
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
|
||||
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
|
||||
- ../vue-chatvideo-layer/i18n/locales/en.json
|
||||
- ../vue-chatvideo-layer/i18n/locales/pl.json
|
||||
- ../vue-chatvideo-layer/tests/useCall.test.ts
|
||||
- ../vue-chatvideo-layer/tests/videoClient.test.ts
|
||||
- ../vue-chatvideo-layer/README.md
|
||||
- ../vue-chat-layer/app/lib/markdown.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.ts
|
||||
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
|
||||
- ../vue-chat-layer/README.md
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
|
||||
- ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
|
||||
|
||||
key-decisions:
|
||||
- "A stale start or join answer closes its session (end for the caller, leave for the callee) only when no newer attempt replaced it; a newer attempt owns the session"
|
||||
- "leave() before the call connects posts end for the caller and leave for the callee (not the room-based alone rule), and only once the attempt has its answer"
|
||||
- "WR-05 uses the server's view: GET status, end when at most one participant is joined; unreadable status (null, undefined, rejection) falls back to end for the caller and leave for the callee. The same rule applies to a failed livekit-client import"
|
||||
- "callAutostart: start needs a waiting start answer (hasStart, left for begin to take); join takes an Accept marker. ?start=1 only chooses Start call over Join call"
|
||||
- "honestHttpUrl also refuses invisible characters (Default_Ignorable_Code_Point, Cf) in a host, which the Unicode-host branch would otherwise accept"
|
||||
|
||||
patterns-established:
|
||||
- "Leaving at every await of begin() is a test: deferred promises for start, join, connect and the camera setter, and a gated vi.doMock of livekit-client for the import"
|
||||
|
||||
requirements-completed: [SC-2, D-02, D-04, D-11, D-13, D-17, D-23]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "CR-01: leaving or unmounting at any moment of a call attempt never connects a Room or turns on media afterwards"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-chatvideo-layer/tests/useCall.test.ts (leave while start is pending; unmount while join is pending; leave while livekit-client loads; leave while connect is pending; a connect that fails after a leave posts nothing more; leave between camera and microphone; a second begin while the first join is pending)"
|
||||
status: pass
|
||||
- id: D2
|
||||
description: "WR-05: a failed connect ends a session nobody else joined, else leaves"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-chatvideo-layer/tests/useCall.test.ts (a failed LiveKit connect fails the call; an unreadable status ends the starter's session; a failed connect ends a session nobody else joined; a failed connect leaves when the other side joined; a failed connect with an unreadable status: the starter ends, the callee leaves)"
|
||||
status: pass
|
||||
- id: D3
|
||||
description: "WR-04: the call page asks before media unless the call button or Accept just handed over"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-chatvideo-layer/tests/videoClient.test.ts (describe call autostart)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "pnpm --dir ../vue-chatvideo-layer run build:playground (Call.vue and IncomingCall.vue compile)"
|
||||
status: pass
|
||||
- id: D4
|
||||
description: "CR-02 client side: deceptive link hosts render as text, never as a link or a card"
|
||||
requirement: D-17
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../vue-chat-layer/app/lib/urlGuard.test.ts; ../vue-chat-layer/app/lib/linkEmbeds.test.ts"
|
||||
status: pass
|
||||
- id: D5
|
||||
description: "WR-01: logout and unauthorized() reset the chat, close the socket and dismiss the ring"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts (createChatAuth with a sign-out, createCommunitySignOut, endSession)"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "pnpm --dir ../sm-demo-app/vue-demo-app run build on the v0.1.0 pins"
|
||||
status: pass
|
||||
|
||||
duration: 14min
|
||||
completed: 2026-11-02
|
||||
---
|
||||
|
||||
# Phase 20 Plan 08: Call and session fixes Summary
|
||||
|
||||
**Leaving a call is now final at any await of the attempt (per-attempt token with stale checks), a failed connect ends an empty session from GET status, the call page asks before media unless the call button or Accept handed over in memory, the chat renders deceptive link hosts as text through `honestHttpUrl`, and logout resets the chat, the realtime socket and the ring in the same tab.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 14 min
|
||||
- **Started:** 2026-11-02T17:25:39Z
|
||||
- **Completed:** 2026-11-02T17:39:00Z
|
||||
- **Tasks:** 5 of 5
|
||||
- **Files modified:** 20 (2 created) across three repositories
|
||||
|
||||
## Commits per repository
|
||||
|
||||
Nothing was pushed or tagged. vue-demo-app keeps its v0.1.0 layer pins. The sm-demo-app gitlink moved but is not staged (` M vue-demo-app`); 20-09 bumps it.
|
||||
|
||||
| Repo | Hash | Subject |
|
||||
|------|------|---------|
|
||||
| ../vue-chatvideo-layer | 69e8b83 | fix: leaving while a call starts never connects it |
|
||||
| ../vue-chatvideo-layer | 27ad81c | fix: a call left at any moment stays left, and a failed connect ends an empty session |
|
||||
| ../vue-chatvideo-layer | 5386903 | feat: the call page asks before it starts the camera and microphone |
|
||||
| ../vue-chat-layer | a798bb1 | fix: deceptive link hosts render as text, not links or cards |
|
||||
| ../sm-demo-app/vue-demo-app | 188ae25 | fix: logging out resets the chat, closes the realtime socket and clears a ringing call |
|
||||
|
||||
## Test results
|
||||
|
||||
| Suite | Before | After |
|
||||
|-------|--------|-------|
|
||||
| vue-chatvideo-layer `pnpm test` | 114 | 127 passed (3 files) |
|
||||
| vue-chat-layer `pnpm test` | 146 | 215 passed (10 files) |
|
||||
| vue-demo-app `pnpm test` | 218 | 225 passed (18 files) |
|
||||
|
||||
- `pnpm --dir ../vue-chatvideo-layer run build:playground` passed. So did `pnpm --dir ../vue-chat-layer run build:playground` and `pnpm --dir ../sm-demo-app/vue-demo-app run build` (v0.1.0 pins). In the built bundle, the plugin calls the resolved `useChat().reset()`, `disconnect()` and `useIncomingCalls().dismiss()`.
|
||||
- The SPA's copied chat tests (chatEvents, chatFormat, chatText, chatUnread, realtime) are byte-identical to b6d3389 (`git diff --quiet` exit 0).
|
||||
- No package.json or lockfile changed in any repo. Neither layer names an application (grep count 0).
|
||||
|
||||
## New tests per finding
|
||||
|
||||
- **CR-01** (useCall.test.ts):
|
||||
- leave while start is pending: no room, no media, end once the session exists (Task 1, red first)
|
||||
- unmount while join is pending: no room, no media, leave once the session exists
|
||||
- leave while connect is pending: no media, the Room disconnected, end once
|
||||
- a connect that fails after a leave posts nothing more
|
||||
- leave between camera and microphone: the microphone stays off, the Room disconnected
|
||||
- a second begin while the first join is pending creates no second Room
|
||||
- leave while livekit-client loads: no Room after the leave, end once. This one stalls the import itself. `vi.resetModules()` plus `vi.doMock('livekit-client')` with an async factory gated on a deferred, then a fresh import of useCall; the `finally` restores the plain fake. The test is last in the file, and five consecutive runs were stable.
|
||||
- **WR-05** (useCall.test.ts): a failed connect ends a session nobody else joined; a failed connect leaves when the other side joined; a failed connect with an unreadable status: the starter ends, the callee leaves.
|
||||
- **Changed expectation:** the old test "a failed LiveKit connect fails the call and leaves the session" is now "a failed LiveKit connect fails the call; an unreadable status ends the starter's session". The stub's `status` answers undefined, which counts as unreadable, so the starter posts `end`, not `leave`. Every other expectation stays.
|
||||
- **WR-04** (videoClient.test.ts, `describe('call autostart')`):
|
||||
- a start begins on mount only with the call button's answer, which begin still takes
|
||||
- a join begins on mount only once, right after Accept
|
||||
- a start answer does not make a join begin, nor an Accept a start
|
||||
- **CR-02 client** (urlGuard.test.ts, new):
|
||||
- `honestHttpUrl` refuses 16 deceptive forms. These are the review's table plus `https:///`, `https:bit.ly`, soft hyphen, ZWSP and `0x7f.1`, along with other schemes and unparseable input.
|
||||
- It accepts 8 honest forms: a port with query and fragment, an IDN in Unicode and in punycode, a subdomain, upper case, `@` in the path, and `%` in the query.
|
||||
- `renderChatText` (bare and `[label](url)`), `safeUrl`, `messageUrls` and `linkify` behave on both sets.
|
||||
- linkEmbeds.test.ts has two new cases.
|
||||
- **WR-01** (communityAuth.test.ts): unauthorized clears the token, then signs out once. createCommunitySignOut runs its parts in order, keeps going when a part throws, and is safe to call twice. endSession posts then clears then signs out, clears and signs out when the post rejects, and posts nothing without a token.
|
||||
|
||||
## For 20-09 (browser checks and release)
|
||||
|
||||
- **Layer exports added:** vue-chatvideo-layer `app/lib/videoClient.ts`: `hasStart`, `rememberAccept`, `takeAccept`, `callAutostart`. vue-chat-layer `app/lib/urlGuard.ts`: `honestHttpUrl`. vue-demo-app `app/utils/communityAuth.ts`: `createCommunitySignOut`, `endSession`, and an optional third parameter `onSignedOut` on `createChatAuth`. The plugin provides `$communitySignOut`.
|
||||
- **Class names:** `.chatvideo-confirm` (the confirmation block), `.chatvideo-confirm__go` (Start call / Join call button), `.chatvideo-confirm__text`, `.chatvideo-confirm__back` (the back link when `backPath` is set).
|
||||
- **Locale keys:** `chatvideo.confirm.start`, `chatvideo.confirm.join`, `chatvideo.action.startCall` (en "Start call", pl "Rozpocznij rozmowę"), `chatvideo.action.joinCall` (en "Join call", pl "Dołącz do rozmowy").
|
||||
- **Browser-visible behaviour changes once the pins move:**
|
||||
- A direct load of `/call/{id}` or `/call/{id}?start=1` shows the confirmation and posts nothing until the click.
|
||||
- The DM call button (start handoff) and the incoming prompt's Accept still go straight in.
|
||||
- The callee's status now passes through `starting` before `connecting`; both render "Connecting…".
|
||||
- Existing browser checks that load `/call/{id}?start=1` directly and expect media must click `.chatvideo-confirm__go` first.
|
||||
- **Demo call page doc comment** (`vue-demo-app/app/pages/call/[conversation].vue`) still says "With ?start=1 … it starts the call". Per the plan it is updated with the pin bump in 20-09.
|
||||
- **New layer tags:** each layer needs one (main is ahead of v0.1.0: chatvideo 3 commits, chat 1 commit on top of the 2 already after the tag) before vue-demo-app can pin them.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter. In short:
|
||||
- **Stale answer:** it closes its session only when it is still the current attempt.
|
||||
- **Leave before connect:** the caller posts end and the callee posts leave, and only once the answer has arrived.
|
||||
- **WR-05:** the rule follows GET status, with a fallback by role when the status cannot be read.
|
||||
- **Autostart:** a start needs the waiting start answer; a join takes the Accept marker.
|
||||
- **Invisible characters:** hosts containing them are refused.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical] Session-channel handle closed when the attempt goes stale while subscribing**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** `openChannel` set `channel` after `await subscribe`. A leave during the subscribe left the handle open (the review's "the channel handle leaks"). A stale attempt's publications could still call `finish`.
|
||||
- **Fix:** `openChannel(sessionId, stale)` closes the handle when the attempt is stale on arrival, and ignores publications for a stale attempt.
|
||||
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- **Commit:** 27ad81c
|
||||
|
||||
**2. [Rule 1 - Bug] A failed livekit-client import left the session ringing, like a failed connect**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** WR-05's cause also applied to the import-failure branch, which only disconnected.
|
||||
- **Fix:** That branch now uses the same `closeFailed` (GET status) rule.
|
||||
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- **Commit:** 27ad81c
|
||||
|
||||
**3. [Rule 2 - Missing critical] Invisible characters in a Unicode host**
|
||||
- **Found during:** Task 4
|
||||
- **Issue:** The plan's Unicode-host branch (NFC, only U+002E separators) accepted `https://bit.l\u00ady/x`, which displays as and opens `bit.ly`. A soft hyphen or ZWSP is dropped by the URL parser.
|
||||
- **Fix:** `honestHttpUrl` refuses hosts containing `\p{Default_Ignorable_Code_Point}` or `\p{Cf}`. Both forms are under test.
|
||||
- **Files modified:** ../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts
|
||||
- **Commit:** a798bb1
|
||||
|
||||
**4. [Rule 1 - Bug] leave() before connect no longer ends the caller's call from the callee side**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The old rule `alone = !room || room.remoteParticipants.size === 0` made a callee who left while connecting post `end`, which ended the waiting caller's call.
|
||||
- **Fix:** Before `connected`, the caller posts end and the callee leave, as the plan's truth states. Once connected, the room-based last-one-out rule still applies.
|
||||
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
|
||||
- **Commit:** 69e8b83
|
||||
|
||||
### Other notes
|
||||
|
||||
- The `leave while livekit-client loads` test stalls the dynamic import itself (the preferred variant in the plan), not the same-tick fallback.
|
||||
- **Not done here, per the plan:** IN-07 (the handle race between two pending incoming subscribes) stays deferred. The demo's call page comment waits for 20-09.
|
||||
|
||||
## Deferred Issues
|
||||
|
||||
- `tsc --noEmit -p .nuxt/tsconfig.app.json` in vue-demo-app reports a type error at `app/plugins/auth.ts` on the `fetchJson` line (`$fetch(url, opts)` with `method?: string`). The line was there before this plan (it is line 20 at deb809f) and does not affect the build. Out of scope.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The changes close T-20-42 to T-20-46 and add no new endpoint, auth path or storage.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: ../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts
|
||||
- FOUND commits: 69e8b83, 27ad81c, 5386903 (vue-chatvideo-layer), a798bb1 (vue-chat-layer), 188ae25 (vue-demo-app)
|
||||
Reference in New Issue
Block a user