diff --git a/apps/web/drizzle/0046_session_observation_shares.sql b/apps/web/drizzle/0046_session_observation_shares.sql
new file mode 100644
index 00000000..2a3e6f19
--- /dev/null
+++ b/apps/web/drizzle/0046_session_observation_shares.sql
@@ -0,0 +1,21 @@
+-- Session observation shares table
+-- Allows time-limited shareable links for observing practice sessions
+CREATE TABLE `session_observation_shares` (
+ `id` text PRIMARY KEY NOT NULL,
+ `session_id` text NOT NULL REFERENCES `session_plans`(`id`) ON DELETE CASCADE,
+ `player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
+ `created_by` text NOT NULL,
+ `created_at` integer NOT NULL DEFAULT (unixepoch()),
+ `expires_at` integer NOT NULL,
+ `status` text NOT NULL DEFAULT 'active',
+ `view_count` integer NOT NULL DEFAULT 0,
+ `last_viewed_at` integer
+);
+--> statement-breakpoint
+
+-- Index for cleanup when session ends
+CREATE INDEX `idx_session_observation_shares_session` ON `session_observation_shares`(`session_id`);
+--> statement-breakpoint
+
+-- Index for listing active shares
+CREATE INDEX `idx_session_observation_shares_status` ON `session_observation_shares`(`status`);
diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json
index 2b928f28..732d6a70 100644
--- a/apps/web/drizzle/meta/_journal.json
+++ b/apps/web/drizzle/meta/_journal.json
@@ -323,6 +323,13 @@
"when": 1766885087540,
"tag": "0045_add_player_stats_table",
"breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "6",
+ "when": 1767052800000,
+ "tag": "0046_session_observation_shares",
+ "breakpoints": true
}
]
}
diff --git a/apps/web/src/app/api/observe/[token]/route.ts b/apps/web/src/app/api/observe/[token]/route.ts
new file mode 100644
index 00000000..0b7a1437
--- /dev/null
+++ b/apps/web/src/app/api/observe/[token]/route.ts
@@ -0,0 +1,124 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { eq } from 'drizzle-orm'
+import { db } from '@/db'
+import { players, sessionPlans } from '@/db/schema'
+import { validateSessionShare } from '@/lib/session-share'
+
+interface RouteParams {
+ params: Promise<{ token: string }>
+}
+
+/**
+ * GET /api/observe/[token]
+ * Validate a share token and return session/player info
+ *
+ * This endpoint does NOT require authentication - anyone with the token can access it.
+ */
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { token } = await params
+
+ try {
+ // Validate the token
+ const validation = await validateSessionShare(token)
+
+ if (!validation.valid || !validation.share) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: validation.error || 'Invalid share link',
+ },
+ { status: 404 }
+ )
+ }
+
+ const share = validation.share
+
+ // Get the session
+ const sessions = await db
+ .select()
+ .from(sessionPlans)
+ .where(eq(sessionPlans.id, share.sessionId))
+ .limit(1)
+
+ const session = sessions[0]
+ if (!session) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: 'Session not found',
+ },
+ { status: 404 }
+ )
+ }
+
+ // Check if session is still active
+ if (session.completedAt) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: 'Session has ended',
+ },
+ { status: 410 } // Gone
+ )
+ }
+
+ if (!session.startedAt) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: 'Session has not started yet',
+ },
+ { status: 425 } // Too Early
+ )
+ }
+
+ // Get the player info
+ const playerResults = await db
+ .select({
+ id: players.id,
+ name: players.name,
+ emoji: players.emoji,
+ color: players.color,
+ })
+ .from(players)
+ .where(eq(players.id, share.playerId))
+ .limit(1)
+
+ const player = playerResults[0]
+ if (!player) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: 'Player not found',
+ },
+ { status: 404 }
+ )
+ }
+
+ return NextResponse.json({
+ valid: true,
+ session: {
+ id: session.id,
+ playerId: session.playerId,
+ startedAt:
+ session.startedAt instanceof Date ? session.startedAt.getTime() : session.startedAt,
+ },
+ player: {
+ id: player.id,
+ name: player.name,
+ emoji: player.emoji,
+ color: player.color,
+ },
+ expiresAt: share.expiresAt instanceof Date ? share.expiresAt.getTime() : share.expiresAt,
+ })
+ } catch (error) {
+ console.error('Error validating share token:', error)
+ return NextResponse.json(
+ {
+ valid: false,
+ error: 'Failed to validate share link',
+ },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/web/src/app/api/sessions/[sessionId]/share/route.ts b/apps/web/src/app/api/sessions/[sessionId]/share/route.ts
new file mode 100644
index 00000000..21fa5b48
--- /dev/null
+++ b/apps/web/src/app/api/sessions/[sessionId]/share/route.ts
@@ -0,0 +1,152 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { canPerformAction, isParentOf } from '@/lib/classroom'
+import { getSessionPlan } from '@/lib/curriculum'
+import {
+ createSessionShare,
+ getActiveSharesForSession,
+ revokeSessionShare,
+ type ShareDuration,
+} from '@/lib/session-share'
+import { getDbUserId } from '@/lib/viewer'
+
+interface RouteParams {
+ params: Promise<{ sessionId: string }>
+}
+
+/**
+ * POST /api/sessions/[sessionId]/share
+ * Create a new share link for a session
+ *
+ * Body: { expiresIn: '1h' | '24h' }
+ * Returns: { token, url, expiresAt }
+ */
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { sessionId } = await params
+
+ try {
+ // Get current user
+ const userId = await getDbUserId()
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Get the session to find the player ID
+ const session = await getSessionPlan(sessionId)
+ if (!session) {
+ return NextResponse.json({ error: 'Session not found' }, { status: 404 })
+ }
+
+ // Check authorization - only parents can create share links (not teachers)
+ const isParent = await isParentOf(userId, session.playerId)
+ if (!isParent) {
+ return NextResponse.json({ error: 'Only parents can create share links' }, { status: 403 })
+ }
+
+ // Parse request body
+ const body = await request.json()
+ const expiresIn = body.expiresIn as ShareDuration
+ if (expiresIn !== '1h' && expiresIn !== '24h') {
+ return NextResponse.json({ error: 'Invalid expiresIn value' }, { status: 400 })
+ }
+
+ // Create the share
+ const share = await createSessionShare(sessionId, session.playerId, userId, expiresIn)
+
+ // Build the full URL
+ const baseUrl = request.nextUrl.origin
+ const url = `${baseUrl}/observe/${share.id}`
+
+ return NextResponse.json({
+ token: share.id,
+ url,
+ expiresAt: share.expiresAt.getTime(),
+ })
+ } catch (error) {
+ console.error('Error creating session share:', error)
+ return NextResponse.json({ error: 'Failed to create share link' }, { status: 500 })
+ }
+}
+
+/**
+ * GET /api/sessions/[sessionId]/share
+ * List all active shares for a session
+ */
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { sessionId } = await params
+
+ try {
+ // Get current user
+ const userId = await getDbUserId()
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Get the session to find the player ID
+ const session = await getSessionPlan(sessionId)
+ if (!session) {
+ return NextResponse.json({ error: 'Session not found' }, { status: 404 })
+ }
+
+ // Check authorization
+ const canObserve = await canPerformAction(userId, session.playerId, 'observe')
+ if (!canObserve) {
+ return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
+ }
+
+ // Get active shares
+ const shares = await getActiveSharesForSession(sessionId)
+
+ return NextResponse.json({
+ shares: shares.map((s) => ({
+ token: s.id,
+ expiresAt: s.expiresAt instanceof Date ? s.expiresAt.getTime() : s.expiresAt,
+ viewCount: s.viewCount,
+ createdAt: s.createdAt instanceof Date ? s.createdAt.getTime() : s.createdAt,
+ })),
+ })
+ } catch (error) {
+ console.error('Error listing session shares:', error)
+ return NextResponse.json({ error: 'Failed to list shares' }, { status: 500 })
+ }
+}
+
+/**
+ * DELETE /api/sessions/[sessionId]/share?token=xxx
+ * Revoke a specific share link
+ */
+export async function DELETE(request: NextRequest, { params }: RouteParams) {
+ const { sessionId } = await params
+ const token = request.nextUrl.searchParams.get('token')
+
+ if (!token) {
+ return NextResponse.json({ error: 'Token required' }, { status: 400 })
+ }
+
+ try {
+ // Get current user
+ const userId = await getDbUserId()
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Get the session to find the player ID
+ const session = await getSessionPlan(sessionId)
+ if (!session) {
+ return NextResponse.json({ error: 'Session not found' }, { status: 404 })
+ }
+
+ // Check authorization - only parents can revoke share links
+ const isParent = await isParentOf(userId, session.playerId)
+ if (!isParent) {
+ return NextResponse.json({ error: 'Only parents can revoke share links' }, { status: 403 })
+ }
+
+ // Revoke the share
+ await revokeSessionShare(token)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Error revoking session share:', error)
+ return NextResponse.json({ error: 'Failed to revoke share' }, { status: 500 })
+ }
+}
diff --git a/apps/web/src/app/observe/[token]/PublicObservationClient.tsx b/apps/web/src/app/observe/[token]/PublicObservationClient.tsx
new file mode 100644
index 00000000..a9f272d5
--- /dev/null
+++ b/apps/web/src/app/observe/[token]/PublicObservationClient.tsx
@@ -0,0 +1,105 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { SessionObserverView } from '@/components/classroom'
+import type { ActiveSessionInfo } from '@/hooks/useClassroom'
+import { css } from '../../../../styled-system/css'
+
+interface PublicObservationClientProps {
+ session: ActiveSessionInfo
+ shareToken: string
+ student: {
+ name: string
+ emoji: string
+ color: string
+ }
+ expiresAt: number
+}
+
+function formatTimeRemaining(ms: number): string {
+ if (ms <= 0) return 'Expired'
+ const hours = Math.floor(ms / (1000 * 60 * 60))
+ const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
+ if (hours > 0) {
+ return `${hours}h ${minutes}m remaining`
+ }
+ return `${minutes}m remaining`
+}
+
+export function PublicObservationClient({
+ session,
+ shareToken,
+ student,
+ expiresAt,
+}: PublicObservationClientProps) {
+ const [navHeight, setNavHeight] = useState(20) // Minimal padding for public page (no nav)
+ const [timeRemaining, setTimeRemaining] = useState(expiresAt - Date.now())
+
+ // Update countdown every minute
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setTimeRemaining(expiresAt - Date.now())
+ }, 60000)
+ return () => clearInterval(interval)
+ }, [expiresAt])
+
+ // Simple page without full nav (public access)
+ return (
+
+ {/* Expiration banner */}
+
0 ? 'blue.50' : 'red.50',
+ _dark: { backgroundColor: timeRemaining > 0 ? 'blue.900' : 'red.900' },
+ padding: '8px 16px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '8px',
+ fontSize: '0.875rem',
+ color: timeRemaining > 0 ? 'blue.700' : 'red.700',
+ _dark: { color: timeRemaining > 0 ? 'blue.200' : 'red.200' },
+ borderBottom: '1px solid',
+ borderColor: timeRemaining > 0 ? 'blue.200' : 'red.200',
+ _dark: { borderColor: timeRemaining > 0 ? 'blue.800' : 'red.800' },
+ })}
+ >
+ 👁️ View-only access
+ •
+ {formatTimeRemaining(timeRemaining)}
+
+
+ {/* Main content */}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/app/observe/[token]/page.tsx b/apps/web/src/app/observe/[token]/page.tsx
new file mode 100644
index 00000000..05349b38
--- /dev/null
+++ b/apps/web/src/app/observe/[token]/page.tsx
@@ -0,0 +1,92 @@
+import { notFound } from 'next/navigation'
+import { eq } from 'drizzle-orm'
+import { db } from '@/db'
+import { players, sessionPlans } from '@/db/schema'
+import { validateSessionShare } from '@/lib/session-share'
+import type { ActiveSessionInfo } from '@/hooks/useClassroom'
+import { PublicObservationClient } from './PublicObservationClient'
+
+export const dynamic = 'force-dynamic'
+
+interface PublicObservationPageProps {
+ params: Promise<{ token: string }>
+}
+
+export default async function PublicObservationPage({ params }: PublicObservationPageProps) {
+ const { token } = await params
+
+ // Validate the share token
+ const validation = await validateSessionShare(token)
+ if (!validation.valid || !validation.share) {
+ notFound()
+ }
+
+ const share = validation.share
+
+ // Get the session
+ const sessions = await db
+ .select()
+ .from(sessionPlans)
+ .where(eq(sessionPlans.id, share.sessionId))
+ .limit(1)
+
+ const session = sessions[0]
+ if (!session) {
+ notFound()
+ }
+
+ // Check if session is still active
+ if (session.completedAt || !session.startedAt) {
+ notFound()
+ }
+
+ // Get the player
+ const playerResults = await db
+ .select()
+ .from(players)
+ .where(eq(players.id, share.playerId))
+ .limit(1)
+
+ const player = playerResults[0]
+ if (!player) {
+ notFound()
+ }
+
+ // Calculate progress info
+ const parts = session.parts as Array<{ slots: Array }>
+ const totalProblems = parts.reduce((sum, part) => sum + part.slots.length, 0)
+ let completedProblems = 0
+ for (let i = 0; i < session.currentPartIndex; i++) {
+ completedProblems += parts[i]?.slots.length ?? 0
+ }
+ completedProblems += session.currentSlotIndex
+
+ const sessionInfo: ActiveSessionInfo = {
+ sessionId: session.id,
+ playerId: session.playerId,
+ startedAt:
+ session.startedAt instanceof Date
+ ? session.startedAt.toISOString()
+ : String(session.startedAt),
+ currentPartIndex: session.currentPartIndex,
+ currentSlotIndex: session.currentSlotIndex,
+ totalParts: parts.length,
+ totalProblems,
+ completedProblems,
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx
index 45eac5aa..466e3ecb 100644
--- a/apps/web/src/app/practice/PracticeClient.tsx
+++ b/apps/web/src/app/practice/PracticeClient.tsx
@@ -657,6 +657,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
color: observingStudent.color,
}}
observerId={userId}
+ canShare={observingStudent.relationship.isMyChild}
/>
)}
diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
index fde4ec40..9fec47e3 100644
--- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
+++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx
@@ -2864,6 +2864,7 @@ export function DashboardClient({
color: player.color,
}}
observerId={userId}
+ canShare={true}
/>
)}
diff --git a/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx b/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx
index 6f3f2d2a..da71f8d5 100644
--- a/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx
+++ b/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx
@@ -1,7 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
-import { useCallback } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { SessionObserverView } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
@@ -16,10 +16,44 @@ interface ObservationClientProps {
color: string
}
studentId: string
+ /** Whether the observer is a parent of the student (can share session) */
+ isParent?: boolean
}
-export function ObservationClient({ session, observerId, student, studentId }: ObservationClientProps) {
+export function ObservationClient({
+ session,
+ observerId,
+ student,
+ studentId,
+ isParent = false,
+}: ObservationClientProps) {
const router = useRouter()
+ const [navHeight, setNavHeight] = useState(80) // Default fallback
+
+ useEffect(() => {
+ // Measure the actual nav height from the fixed header
+ const measureNavHeight = () => {
+ const header = document.querySelector('header')
+ if (header) {
+ const rect = header.getBoundingClientRect()
+ // Nav top position + nav height + small margin
+ const calculatedHeight = rect.top + rect.height + 16
+ setNavHeight(calculatedHeight)
+ }
+ }
+
+ // Measure on mount and when window resizes
+ measureNavHeight()
+ window.addEventListener('resize', measureNavHeight)
+
+ // Also measure after a short delay to catch any late-rendering nav elements
+ const timer = setTimeout(measureNavHeight, 100)
+
+ return () => {
+ window.removeEventListener('resize', measureNavHeight)
+ clearTimeout(timer)
+ }
+ }, [])
const handleExit = useCallback(() => {
router.push(`/practice/${studentId}/dashboard`, { scroll: false })
@@ -34,25 +68,25 @@ export function ObservationClient({ session, observerId, student, studentId }: O
backgroundColor: 'gray.50',
_dark: { backgroundColor: 'gray.900' },
display: 'flex',
- justifyContent: 'center',
- padding: { base: '16px', md: '24px' },
+ flexDirection: 'column',
+ boxSizing: 'border-box',
})}
+ style={{
+ paddingTop: `${navHeight}px`,
+ }}
>
diff --git a/apps/web/src/app/practice/[studentId]/observe/page.tsx b/apps/web/src/app/practice/[studentId]/observe/page.tsx
index a7bd994e..4d1893ce 100644
--- a/apps/web/src/app/practice/[studentId]/observe/page.tsx
+++ b/apps/web/src/app/practice/[studentId]/observe/page.tsx
@@ -1,5 +1,5 @@
import { notFound, redirect } from 'next/navigation'
-import { canPerformAction } from '@/lib/classroom'
+import { canPerformAction, isParentOf } from '@/lib/classroom'
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { getDbUserId } from '@/lib/viewer'
@@ -23,7 +23,10 @@ export default async function PracticeObservationPage({ params }: ObservationPag
notFound()
}
- const canObserve = await canPerformAction(observerId, studentId, 'observe')
+ const [canObserve, isParent] = await Promise.all([
+ canPerformAction(observerId, studentId, 'observe'),
+ isParentOf(observerId, studentId),
+ ])
if (!canObserve) {
notFound()
}
@@ -60,6 +63,7 @@ export default async function PracticeObservationPage({ params }: ObservationPag
color: player.color,
}}
studentId={studentId}
+ isParent={isParent}
/>
)
}
diff --git a/apps/web/src/components/classroom/SessionObserverModal.tsx b/apps/web/src/components/classroom/SessionObserverModal.tsx
index 6c8b41d5..191226e6 100644
--- a/apps/web/src/components/classroom/SessionObserverModal.tsx
+++ b/apps/web/src/components/classroom/SessionObserverModal.tsx
@@ -10,6 +10,7 @@ import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { useSessionObserver } from '@/hooks/useSessionObserver'
import { css } from '../../../styled-system/css'
import { AbacusDock } from '../AbacusDock'
+import { SessionShareButton } from './SessionShareButton'
import { LiveResultsPanel } from '../practice/LiveResultsPanel'
import { LiveSessionReportInline } from '../practice/LiveSessionReportModal'
import { PracticeFeedback } from '../practice/PracticeFeedback'
@@ -32,12 +33,20 @@ interface SessionObserverModalProps {
}
/** Observer ID (e.g., teacher's user ID) */
observerId: string
+ /** Whether the observer can share this session (parents only) */
+ canShare?: boolean
}
interface SessionObserverViewProps {
session: ActiveSessionInfo
student: SessionObserverModalProps['student']
observerId: string
+ /** Optional share token for public/guest observation (bypasses user auth) */
+ shareToken?: string
+ /** If true, hide all control buttons (pause/resume, dock abacus, share) */
+ isViewOnly?: boolean
+ /** Whether the observer can share this session (parents only) */
+ canShare?: boolean
onClose?: () => void
onRequestFullscreen?: () => void
renderCloseButton?: (button: ReactElement) => ReactElement
@@ -60,6 +69,7 @@ export function SessionObserverModal({
session,
student,
observerId,
+ canShare,
}: SessionObserverModalProps) {
const router = useRouter()
@@ -107,6 +117,7 @@ export function SessionObserverModal({
session={session}
student={student}
observerId={observerId}
+ canShare={canShare}
onClose={onClose}
onRequestFullscreen={handleFullscreen}
renderCloseButton={(button) => {button}}
@@ -122,6 +133,9 @@ export function SessionObserverView({
session,
student,
observerId,
+ shareToken,
+ isViewOnly = false,
+ canShare = false,
onClose,
onRequestFullscreen,
renderCloseButton,
@@ -133,7 +147,7 @@ export function SessionObserverView({
// Subscribe to the session's socket channel
const { state, results, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
- useSessionObserver(session.sessionId, observerId, session.playerId, true)
+ useSessionObserver(session.sessionId, observerId, session.playerId, true, shareToken)
// Track if we've paused the session (teacher controls resume)
const [hasPausedSession, setHasPausedSession] = useState(false)
@@ -339,15 +353,17 @@ export function SessionObserverView({
height: '32px',
borderRadius: '8px',
border: 'none',
- backgroundColor: 'rgba(255, 255, 255, 0.2)',
- color: 'white',
+ backgroundColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
+ color: isDark ? 'white' : 'gray.700',
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
- _hover: { backgroundColor: 'rgba(255, 255, 255, 0.3)' },
+ _hover: {
+ backgroundColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.15)',
+ },
})}
title="Open full-screen observation"
>
@@ -578,74 +594,79 @@ export function SessionObserverView({
- {/* Teacher controls: pause/resume and dock abaci */}
-
- {/* Pause/Resume button */}
- {isObserving && (
-
+ )}
- {/* Dock both abaci button */}
- {state && state.phase === 'problem' && (
-
- 🧮 Dock Abaci
-
- )}
-
+ {/* Dock both abaci button */}
+ {state && state.phase === 'problem' && (
+
+ 🧮 Dock Abaci
+
+ )}
+
+ {/* Share session link button (parents only) */}
+ {canShare && }
+
+ )}
)
diff --git a/apps/web/src/components/classroom/SessionShareButton.tsx b/apps/web/src/components/classroom/SessionShareButton.tsx
new file mode 100644
index 00000000..eb9cc903
--- /dev/null
+++ b/apps/web/src/components/classroom/SessionShareButton.tsx
@@ -0,0 +1,521 @@
+'use client'
+
+import * as Popover from '@radix-ui/react-popover'
+import { useCallback, useEffect, useState } from 'react'
+import { Z_INDEX } from '@/constants/zIndex'
+import { getShareUrl } from '@/lib/share/urls'
+import { css } from '../../../styled-system/css'
+import { AbacusQRCode } from '../common/AbacusQRCode'
+import { CopyButton } from '../common/CopyButton'
+
+interface ShareInfo {
+ token: string
+ url: string
+ expiresAt: number
+ viewCount?: number
+ createdAt?: number
+}
+
+interface SessionShareButtonProps {
+ sessionId: string
+ isDark: boolean
+}
+
+function formatTimeRemaining(expiresAt: number): string {
+ const remaining = expiresAt - Date.now()
+ if (remaining <= 0) return 'Expired'
+ const hours = Math.floor(remaining / (1000 * 60 * 60))
+ const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`
+ }
+ return `${minutes}m`
+}
+
+function formatCreatedAt(createdAt: number): string {
+ const now = Date.now()
+ const diff = now - createdAt
+ const minutes = Math.floor(diff / (1000 * 60))
+ const hours = Math.floor(diff / (1000 * 60 * 60))
+
+ if (minutes < 1) return 'Just now'
+ if (minutes < 60) return `${minutes}m ago`
+ if (hours < 24) return `${hours}h ago`
+ return new Date(createdAt).toLocaleDateString()
+}
+
+export function SessionShareButton({ sessionId, isDark }: SessionShareButtonProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [expiresIn, setExpiresIn] = useState<'1h' | '24h'>('1h')
+ const [isGenerating, setIsGenerating] = useState(false)
+ const [generatedShare, setGeneratedShare] = useState(null)
+ const [activeShares, setActiveShares] = useState([])
+ const [error, setError] = useState(null)
+
+ // Fetch active shares when popover opens
+ useEffect(() => {
+ if (isOpen) {
+ fetchActiveShares()
+ }
+ }, [isOpen, sessionId])
+
+ const fetchActiveShares = async () => {
+ try {
+ const response = await fetch(`/api/sessions/${sessionId}/share`)
+ if (response.ok) {
+ const data = await response.json()
+ setActiveShares(
+ data.shares.map(
+ (s: { token: string; expiresAt: number; viewCount?: number; createdAt?: number }) => ({
+ token: s.token,
+ url: getShareUrl('observe', s.token),
+ expiresAt: s.expiresAt,
+ viewCount: s.viewCount,
+ createdAt: s.createdAt,
+ })
+ )
+ )
+ }
+ } catch (err) {
+ console.error('Failed to fetch active shares:', err)
+ }
+ }
+
+ const handleGenerate = async () => {
+ setIsGenerating(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`/api/sessions/${sessionId}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ expiresIn }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json()
+ throw new Error(data.error || 'Failed to generate share link')
+ }
+
+ const data = await response.json()
+ const share: ShareInfo = {
+ token: data.token,
+ url: data.url,
+ expiresAt: data.expiresAt,
+ createdAt: Date.now(),
+ viewCount: 0,
+ }
+ setGeneratedShare(share)
+ setActiveShares((prev) => [...prev, share])
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to generate share link')
+ } finally {
+ setIsGenerating(false)
+ }
+ }
+
+ const handleRevoke = useCallback(
+ async (token: string) => {
+ try {
+ const response = await fetch(`/api/sessions/${sessionId}/share?token=${token}`, {
+ method: 'DELETE',
+ })
+ if (response.ok) {
+ setActiveShares((prev) => prev.filter((s) => s.token !== token))
+ if (generatedShare?.token === token) {
+ setGeneratedShare(null)
+ }
+ }
+ } catch (err) {
+ console.error('Failed to revoke share:', err)
+ }
+ },
+ [sessionId, generatedShare?.token]
+ )
+
+ return (
+
+
+
+ 🔗 Share
+
+
+
+
+
+
+
+ Share Session Link
+
+
+ {/* Generated link display - horizontal layout */}
+ {generatedShare && (
+
+ {/* QR Code - left side */}
+
+
+
+ ⏱️ {formatTimeRemaining(generatedShare.expiresAt)}
+
+
+
+ {/* Copy buttons - right side */}
+
+
+ ✓ Link created!
+
+
+
+
setGeneratedShare(null)}
+ className={css({
+ padding: '6px',
+ backgroundColor: 'transparent',
+ color: isDark ? 'gray.400' : 'gray.500',
+ border: '1px dashed',
+ borderColor: isDark ? 'gray.600' : 'gray.300',
+ borderRadius: '6px',
+ fontSize: '0.75rem',
+ cursor: 'pointer',
+ _hover: { borderColor: isDark ? 'gray.500' : 'gray.400' },
+ })}
+ >
+ + Generate another
+
+
+
+ )}
+
+ {/* Expiration selector - compact horizontal */}
+ {!generatedShare && (
+
+
+
+ setExpiresIn('1h')}
+ className={css({
+ padding: '6px 12px',
+ fontSize: '0.8125rem',
+ backgroundColor:
+ expiresIn === '1h'
+ ? isDark
+ ? 'purple.600'
+ : 'purple.500'
+ : isDark
+ ? 'gray.700'
+ : 'gray.100',
+ color: expiresIn === '1h' ? 'white' : isDark ? 'gray.300' : 'gray.700',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ })}
+ >
+ 1h
+
+ setExpiresIn('24h')}
+ className={css({
+ padding: '6px 12px',
+ fontSize: '0.8125rem',
+ backgroundColor:
+ expiresIn === '24h'
+ ? isDark
+ ? 'purple.600'
+ : 'purple.500'
+ : isDark
+ ? 'gray.700'
+ : 'gray.100',
+ color: expiresIn === '24h' ? 'white' : isDark ? 'gray.300' : 'gray.700',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ })}
+ >
+ 24h
+
+
+
+ {isGenerating ? 'Generating...' : 'Generate'}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ {/* Active shares list - scrollable, compact grid layout */}
+ {activeShares.length > 0 && (
+
+
+ Active Links ({activeShares.length})
+
+ {activeShares.reduce((sum, s) => sum + (s.viewCount ?? 0), 0)} total views
+
+
+
+ {activeShares.map((share) => (
+
+ {/* Share info - compact */}
+
+
+ ...{share.token.slice(-6)}
+
+
+ ⏱️ {formatTimeRemaining(share.expiresAt)}
+ 👁️ {share.viewCount ?? 0}
+
+
+
+ {/* Actions - inline */}
+
+ navigator.clipboard.writeText(share.url)}
+ title="Copy link"
+ className={css({
+ padding: '4px 6px',
+ backgroundColor: 'transparent',
+ color: isDark ? 'gray.300' : 'gray.600',
+ border: '1px solid',
+ borderColor: isDark ? 'gray.600' : 'gray.300',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '0.625rem',
+ _hover: {
+ backgroundColor: isDark ? 'gray.600' : 'gray.200',
+ },
+ })}
+ >
+ 📋
+
+ handleRevoke(share.token)}
+ title="Revoke"
+ className={css({
+ padding: '4px 6px',
+ backgroundColor: 'transparent',
+ color: 'red.500',
+ border: '1px solid',
+ borderColor: isDark ? 'red.800' : 'red.200',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '0.625rem',
+ _hover: {
+ backgroundColor: isDark ? 'red.900/50' : 'red.50',
+ },
+ })}
+ >
+ ✕
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/classroom/index.ts b/apps/web/src/components/classroom/index.ts
index 6e5dfe08..a46e2ec4 100644
--- a/apps/web/src/components/classroom/index.ts
+++ b/apps/web/src/components/classroom/index.ts
@@ -6,4 +6,5 @@ export { EnrollChildModal } from './EnrollChildModal'
export { EnterClassroomButton } from './EnterClassroomButton'
export { PendingApprovalsSection } from './PendingApprovalsSection'
export { SessionObserverModal, SessionObserverView } from './SessionObserverModal'
+export { SessionShareButton } from './SessionShareButton'
export { TeacherEnrollmentSection } from './TeacherEnrollmentSection'
diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts
index b14554a0..daf07620 100644
--- a/apps/web/src/components/practice/hooks/useInteractionPhase.ts
+++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts
@@ -690,7 +690,10 @@ export function useInteractionPhase(
// Unambiguous intermediate prefix match (e.g., "03" for prefix sum 3)
// Immediately enter help mode
// Keep userAnswer during transition so it shows in answer boxes while fading out
- const helpContext = computeHelpContext(attempt.problem.terms, newPrefixMatch.helpTermIndex)
+ const helpContext = computeHelpContext(
+ attempt.problem.terms,
+ newPrefixMatch.helpTermIndex
+ )
return {
phase: 'helpMode',
attempt: { ...updatedAttempt, userAnswer: '' },
diff --git a/apps/web/src/db/schema/index.ts b/apps/web/src/db/schema/index.ts
index d5286444..b7a452a3 100644
--- a/apps/web/src/db/schema/index.ts
+++ b/apps/web/src/db/schema/index.ts
@@ -20,6 +20,7 @@ export * from './player-skill-mastery'
export * from './player-stats'
export * from './players'
export * from './practice-sessions'
+export * from './session-observation-shares'
export * from './session-plans'
export * from './skill-tutorial-progress'
export * from './room-bans'
diff --git a/apps/web/src/db/schema/session-observation-shares.ts b/apps/web/src/db/schema/session-observation-shares.ts
new file mode 100644
index 00000000..8a64fa9f
--- /dev/null
+++ b/apps/web/src/db/schema/session-observation-shares.ts
@@ -0,0 +1,57 @@
+import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
+import { players } from './players'
+import { sessionPlans } from './session-plans'
+
+/**
+ * Shareable observation links for practice sessions
+ *
+ * Allows parents/teachers to share time-limited links that anyone can use
+ * to observe a student's practice session without logging in.
+ */
+export const sessionObservationShares = sqliteTable(
+ 'session_observation_shares',
+ {
+ // 10-char base62 token (cryptographically random)
+ id: text('id').primaryKey(),
+
+ // Session being shared
+ sessionId: text('session_id')
+ .notNull()
+ .references(() => sessionPlans.id, { onDelete: 'cascade' }),
+
+ // Player being observed (denormalized for fast lookup)
+ playerId: text('player_id')
+ .notNull()
+ .references(() => players.id, { onDelete: 'cascade' }),
+
+ // Who created the share link
+ createdBy: text('created_by').notNull(),
+
+ // Timestamps
+ createdAt: integer('created_at', { mode: 'timestamp' })
+ .notNull()
+ .$defaultFn(() => new Date()),
+
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
+
+ // Status: active, expired (time-based), or revoked (manually)
+ status: text('status', {
+ enum: ['active', 'expired', 'revoked'],
+ })
+ .notNull()
+ .default('active'),
+
+ // Analytics
+ viewCount: integer('view_count').notNull().default(0),
+ lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }),
+ },
+ (table) => ({
+ // Index for cleanup when session ends
+ sessionIdx: index('idx_session_observation_shares_session').on(table.sessionId),
+ // Index for listing active shares
+ statusIdx: index('idx_session_observation_shares_status').on(table.status),
+ })
+)
+
+export type SessionObservationShare = typeof sessionObservationShares.$inferSelect
+export type NewSessionObservationShare = typeof sessionObservationShares.$inferInsert
diff --git a/apps/web/src/hooks/useSessionObserver.ts b/apps/web/src/hooks/useSessionObserver.ts
index 26b78498..6360a9c3 100644
--- a/apps/web/src/hooks/useSessionObserver.ts
+++ b/apps/web/src/hooks/useSessionObserver.ts
@@ -125,12 +125,14 @@ interface UseSessionObserverResult {
* @param observerId - Unique identifier for this observer (e.g., teacher's/parent's user ID)
* @param playerId - The player ID being observed (for authorization check)
* @param enabled - Whether to start observing (default: true)
+ * @param shareToken - Optional share token for public/guest observation (bypasses user auth)
*/
export function useSessionObserver(
sessionId: string | undefined,
observerId: string | undefined,
playerId: string | undefined,
- enabled = true
+ enabled = true,
+ shareToken?: string
): UseSessionObserverResult {
const [state, setState] = useState(null)
const [results, setResults] = useState([])
@@ -156,7 +158,10 @@ export function useSessionObserver(
}, [sessionId])
useEffect(() => {
- if (!sessionId || !observerId || !playerId || !enabled) {
+ // Need sessionId and either (observerId + playerId) or shareToken
+ const hasAuthCredentials = observerId && playerId
+ const hasShareToken = !!shareToken
+ if (!sessionId || (!hasAuthCredentials && !hasShareToken) || !enabled) {
// Clean up if disabled
if (socketRef.current) {
stopObserving()
@@ -178,8 +183,12 @@ export function useSessionObserver(
setIsConnected(true)
setError(null)
- // Join the session channel as an observer (includes playerId for authorization)
- socket.emit('observe-session', { sessionId, observerId, playerId })
+ // Join the session channel - use shareToken if available, otherwise authenticated flow
+ if (shareToken) {
+ socket.emit('observe-session', { sessionId, shareToken })
+ } else {
+ socket.emit('observe-session', { sessionId, observerId, playerId })
+ }
setIsObserving(true)
})
@@ -264,7 +273,7 @@ export function useSessionObserver(
socket.disconnect()
socketRef.current = null
}
- }, [sessionId, observerId, playerId, enabled, stopObserving])
+ }, [sessionId, observerId, playerId, enabled, stopObserving, shareToken])
// Send control action to student's abacus
const sendControl = useCallback(
diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts
index 8d53f963..bd9ea9ed 100644
--- a/apps/web/src/lib/curriculum/session-planner.ts
+++ b/apps/web/src/lib/curriculum/session-planner.ts
@@ -59,6 +59,7 @@ import {
recordSkillAttemptsWithHelp,
} from './progress-manager'
import { getWeakSkillIds, type SessionMode } from './session-mode'
+import { revokeSharesForSession } from '@/lib/session-share'
// ============================================================================
// Plan Generation
@@ -813,6 +814,16 @@ export async function recordSlotResult(
}
}
+ // Revoke any active share links when session completes
+ if (isComplete) {
+ try {
+ await revokeSharesForSession(planId)
+ } catch (shareError) {
+ // Non-critical: log and continue (session already completed successfully)
+ console.error(`[recordSlotResult] revokeSharesForSession FAILED:`, shareError)
+ }
+ }
+
return updated
}
@@ -849,6 +860,13 @@ export async function completeSessionPlanEarly(
.where(eq(schema.sessionPlans.id, planId))
.returning()
+ // Revoke any active share links
+ try {
+ await revokeSharesForSession(planId)
+ } catch (shareError) {
+ console.error(`[completeSessionPlanEarly] revokeSharesForSession FAILED:`, shareError)
+ }
+
return updated
}
@@ -864,6 +882,14 @@ export async function abandonSessionPlan(planId: string): Promise {
})
.where(eq(schema.sessionPlans.id, planId))
.returning()
+
+ // Revoke any active share links
+ try {
+ await revokeSharesForSession(planId)
+ } catch (shareError) {
+ console.error(`[abandonSessionPlan] revokeSharesForSession FAILED:`, shareError)
+ }
+
return updated
}
diff --git a/apps/web/src/lib/session-share.ts b/apps/web/src/lib/session-share.ts
new file mode 100644
index 00000000..a487e5f9
--- /dev/null
+++ b/apps/web/src/lib/session-share.ts
@@ -0,0 +1,215 @@
+import { and, eq } from 'drizzle-orm'
+import { db } from '@/db'
+import {
+ sessionObservationShares,
+ type NewSessionObservationShare,
+ type SessionObservationShare,
+} from '@/db/schema'
+
+// ============================================================================
+// Token Generation
+// ============================================================================
+
+const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
+const TOKEN_LENGTH = 10 // ~59 bits of entropy
+
+/**
+ * Generate a cryptographically random 10-char base62 token
+ */
+export function generateShareToken(): string {
+ let result = ''
+ const randomBytes = new Uint8Array(TOKEN_LENGTH)
+ crypto.getRandomValues(randomBytes)
+
+ for (let i = 0; i < TOKEN_LENGTH; i++) {
+ result += BASE62_CHARS[randomBytes[i] % BASE62_CHARS.length]
+ }
+
+ return result
+}
+
+/**
+ * Check if a token has valid format
+ */
+export function isValidShareToken(token: string): boolean {
+ if (token.length !== TOKEN_LENGTH) return false
+
+ for (let i = 0; i < token.length; i++) {
+ if (!BASE62_CHARS.includes(token[i])) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// ============================================================================
+// Expiration Helpers
+// ============================================================================
+
+export type ShareDuration = '1h' | '24h'
+
+/**
+ * Calculate expiration timestamp from duration
+ */
+export function getExpirationTime(duration: ShareDuration): Date {
+ const now = new Date()
+ switch (duration) {
+ case '1h':
+ return new Date(now.getTime() + 60 * 60 * 1000)
+ case '24h':
+ return new Date(now.getTime() + 24 * 60 * 60 * 1000)
+ }
+}
+
+// ============================================================================
+// CRUD Operations
+// ============================================================================
+
+/**
+ * Create a new session share link
+ */
+export async function createSessionShare(
+ sessionId: string,
+ playerId: string,
+ createdBy: string,
+ duration: ShareDuration
+): Promise {
+ const token = generateShareToken()
+ const expiresAt = getExpirationTime(duration)
+
+ const newShare: NewSessionObservationShare = {
+ id: token,
+ sessionId,
+ playerId,
+ createdBy,
+ expiresAt,
+ status: 'active',
+ viewCount: 0,
+ }
+
+ await db.insert(sessionObservationShares).values(newShare)
+
+ return {
+ ...newShare,
+ createdAt: new Date(),
+ lastViewedAt: null,
+ } as SessionObservationShare
+}
+
+/**
+ * Get a session share by token
+ */
+export async function getSessionShare(token: string): Promise {
+ if (!isValidShareToken(token)) {
+ return null
+ }
+
+ const shares = await db
+ .select()
+ .from(sessionObservationShares)
+ .where(eq(sessionObservationShares.id, token))
+ .limit(1)
+
+ return shares[0] || null
+}
+
+/**
+ * Validation result for a share token
+ */
+export interface ShareValidation {
+ valid: boolean
+ error?: string
+ share?: SessionObservationShare
+}
+
+/**
+ * Validate a session share token
+ * Checks: exists, not expired, not revoked, session still active
+ */
+export async function validateSessionShare(token: string): Promise {
+ const share = await getSessionShare(token)
+
+ if (!share) {
+ return { valid: false, error: 'Share link not found' }
+ }
+
+ if (share.status === 'revoked') {
+ return { valid: false, error: 'Share link has been revoked' }
+ }
+
+ if (share.status === 'expired') {
+ return { valid: false, error: 'Share link has expired' }
+ }
+
+ // Check time-based expiration
+ if (new Date() > share.expiresAt) {
+ // Mark as expired in database
+ await db
+ .update(sessionObservationShares)
+ .set({ status: 'expired' })
+ .where(eq(sessionObservationShares.id, token))
+
+ return { valid: false, error: 'Share link has expired' }
+ }
+
+ return { valid: true, share }
+}
+
+/**
+ * Increment the view count for a share
+ */
+export async function incrementShareViewCount(token: string): Promise {
+ const share = await getSessionShare(token)
+ if (!share) return
+
+ await db
+ .update(sessionObservationShares)
+ .set({
+ viewCount: share.viewCount + 1,
+ lastViewedAt: new Date(),
+ })
+ .where(eq(sessionObservationShares.id, token))
+}
+
+/**
+ * Revoke a specific share link
+ */
+export async function revokeSessionShare(token: string): Promise {
+ await db
+ .update(sessionObservationShares)
+ .set({ status: 'revoked' })
+ .where(eq(sessionObservationShares.id, token))
+}
+
+/**
+ * Revoke all active shares for a session (called when session ends)
+ */
+export async function revokeSharesForSession(sessionId: string): Promise {
+ await db
+ .update(sessionObservationShares)
+ .set({ status: 'expired' })
+ .where(
+ and(
+ eq(sessionObservationShares.sessionId, sessionId),
+ eq(sessionObservationShares.status, 'active')
+ )
+ )
+}
+
+/**
+ * Get all active shares for a session
+ */
+export async function getActiveSharesForSession(
+ sessionId: string
+): Promise {
+ return db
+ .select()
+ .from(sessionObservationShares)
+ .where(
+ and(
+ eq(sessionObservationShares.sessionId, sessionId),
+ eq(sessionObservationShares.status, 'active')
+ )
+ )
+}
diff --git a/apps/web/src/lib/share/urls.ts b/apps/web/src/lib/share/urls.ts
index 187a421d..bdb237d3 100644
--- a/apps/web/src/lib/share/urls.ts
+++ b/apps/web/src/lib/share/urls.ts
@@ -2,7 +2,7 @@
* URL helpers for share codes
*/
-export type ShareType = 'classroom' | 'family' | 'room'
+export type ShareType = 'classroom' | 'family' | 'room' | 'observe'
/**
* Get the base URL for the current environment
@@ -27,6 +27,8 @@ export function getShareUrl(type: ShareType, code: string): string {
return `${base}/join/family/${code}`
case 'room':
return `${base}/arcade/join/${code}`
+ case 'observe':
+ return `${base}/observe/${code}`
}
}
@@ -41,5 +43,7 @@ export function getShareTypeLabel(type: ShareType): string {
return 'Family'
case 'room':
return 'Room'
+ case 'observe':
+ return 'Session'
}
}
diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts
index 67e7843e..9d6aacce 100644
--- a/apps/web/src/socket-server.ts
+++ b/apps/web/src/socket-server.ts
@@ -18,6 +18,7 @@ import { getValidator, type GameName } from './lib/arcade/validators'
import type { GameMove } from './lib/arcade/validation/types'
import { getGameConfig } from './lib/arcade/game-config-helpers'
import { canPerformAction, isParentOf } from './lib/classroom'
+import { incrementShareViewCount, validateSessionShare } from './lib/session-share'
// Yjs server-side imports
import * as Y from 'yjs'
@@ -772,19 +773,56 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
// Session Observation: Start observing a practice session
- // Now requires playerId for authorization check (parent or teacher-present)
+ // Supports both authenticated observers (parent/teacher) and token-based shared observers
socket.on(
'observe-session',
async ({
sessionId,
observerId,
playerId,
+ shareToken,
}: {
sessionId: string
- observerId: string
+ observerId?: string
playerId?: string
+ shareToken?: string
}) => {
try {
+ // Token-based authentication (shareable links - no user login required)
+ if (shareToken) {
+ const validation = await validateSessionShare(shareToken)
+ if (!validation.valid) {
+ console.log(`⚠️ Share token validation failed: ${validation.error}`)
+ socket.emit('observe-error', { error: validation.error || 'Invalid share link' })
+ return
+ }
+
+ // Increment view count
+ await incrementShareViewCount(shareToken)
+
+ // Mark this socket as a shared observer (view-only, no controls)
+ socket.data.isSharedObserver = true
+ socket.data.shareToken = shareToken
+
+ await socket.join(`session:${sessionId}`)
+ console.log(
+ `👁️ Shared observer joined session: ${sessionId} (token: ${shareToken.substring(0, 4)}...)`
+ )
+
+ // Notify session that a guest observer joined
+ socket.to(`session:${sessionId}`).emit('observer-joined', {
+ observerId: 'guest',
+ isGuest: true,
+ })
+ return
+ }
+
+ // Authenticated observer flow (parent or teacher-present)
+ if (!observerId) {
+ socket.emit('observe-error', { error: 'Observer ID required' })
+ return
+ }
+
// Authorization check: require 'observe' permission (parent or teacher-present)
if (playerId) {
const canObserve = await canPerformAction(observerId, playerId, 'observe')
@@ -797,6 +835,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
}
+ // Mark as authenticated observer (has controls)
+ socket.data.isSharedObserver = false
+
await socket.join(`session:${sessionId}`)
console.log(`👁️ Observer ${observerId} started watching session: ${sessionId}`)
@@ -845,15 +886,22 @@ export function initializeSocketServer(httpServer: HTTPServer) {
)
// Session Observation: Tutorial control from observer
+ // Shared observers (via token) are view-only and cannot control
socket.on(
'tutorial-control',
(data: { sessionId: string; action: 'skip' | 'next' | 'previous' }) => {
+ // Reject if shared observer (view-only)
+ if (socket.data.isSharedObserver) {
+ console.log('[Socket] tutorial-control rejected - shared observer is view-only')
+ return
+ }
// Send control command to student's client
io!.to(`session:${data.sessionId}`).emit('tutorial-control', data)
}
)
// Session Observation: Abacus control from observer
+ // Shared observers (via token) are view-only and cannot control
socket.on(
'abacus-control',
(data: {
@@ -862,20 +910,37 @@ export function initializeSocketServer(httpServer: HTTPServer) {
action: 'show' | 'hide' | 'set-value'
value?: number
}) => {
+ // Reject if shared observer (view-only)
+ if (socket.data.isSharedObserver) {
+ console.log('[Socket] abacus-control rejected - shared observer is view-only')
+ return
+ }
// Send control command to student's client
io!.to(`session:${data.sessionId}`).emit('abacus-control', data)
}
)
// Session Observation: Pause command from observer (teacher pauses student's session)
+ // Shared observers (via token) are view-only and cannot control
socket.on('session-pause', (data: { sessionId: string; reason: string; message?: string }) => {
+ // Reject if shared observer (view-only)
+ if (socket.data.isSharedObserver) {
+ console.log('[Socket] session-pause rejected - shared observer is view-only')
+ return
+ }
console.log('[Socket] session-pause:', data.sessionId, data.message)
// Forward pause command to student's client
io!.to(`session:${data.sessionId}`).emit('session-paused', data)
})
// Session Observation: Resume command from observer (teacher resumes student's session)
+ // Shared observers (via token) are view-only and cannot control
socket.on('session-resume', (data: { sessionId: string }) => {
+ // Reject if shared observer (view-only)
+ if (socket.data.isSharedObserver) {
+ console.log('[Socket] session-resume rejected - shared observer is view-only')
+ return
+ }
console.log('[Socket] session-resume:', data.sessionId)
// Forward resume command to student's client
io!.to(`session:${data.sessionId}`).emit('session-resumed', data)