From 72e0711f5ba721496b3e64225f3274234067ed05 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:11:04 -0400 Subject: [PATCH 01/29] Add non-secret workspace maintenance identifiers --- api/_workspace.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/_workspace.js b/api/_workspace.js index 5f14007..4957c84 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -102,6 +102,7 @@ async function initializeRepository() { const sql = neon(connection); await sql`CREATE TABLE IF NOT EXISTS collaboration_workspaces ( id BIGSERIAL PRIMARY KEY, + public_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), token_hash CHAR(64) UNIQUE NOT NULL, snapshot_json JSONB NOT NULL, revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), @@ -111,6 +112,10 @@ async function initializeRepository() { team_name VARCHAR(80), next_participant_number INTEGER NOT NULL DEFAULT 1 )`; + await sql`ALTER TABLE collaboration_workspaces ADD COLUMN IF NOT EXISTS public_id UUID DEFAULT gen_random_uuid()`; + await sql`UPDATE collaboration_workspaces SET public_id = gen_random_uuid() WHERE public_id IS NULL`; + await sql`ALTER TABLE collaboration_workspaces ALTER COLUMN public_id SET NOT NULL`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS collaboration_workspaces_public_id_idx ON collaboration_workspaces (public_id)`; await sql`ALTER TABLE collaboration_workspaces ADD COLUMN IF NOT EXISTS team_name VARCHAR(80)`; await sql`ALTER TABLE collaboration_workspaces ADD COLUMN IF NOT EXISTS next_participant_number INTEGER NOT NULL DEFAULT 1`; await sql`CREATE INDEX IF NOT EXISTS collaboration_workspaces_expires_at_idx ON collaboration_workspaces (expires_at)`; From a5dbd16f7df831549ea388c1091f611ea56fc5b6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:12:19 -0400 Subject: [PATCH 02/29] Add Admin maintenance API and lifecycle repository --- api/_admin.js | 791 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 791 insertions(+) create mode 100644 api/_admin.js diff --git a/api/_admin.js b/api/_admin.js new file mode 100644 index 0000000..2add0b7 --- /dev/null +++ b/api/_admin.js @@ -0,0 +1,791 @@ +/** + * @module api/admin + * @description Server-only Administration / Maintenance authorization, inventory, preview, recovery, and purge operations. + * + * Administration is deliberately separate from Standalone / Student / Instructor + * experience roles. The raw admin credential is environment-managed and is never + * persisted by this module. + */ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +import { getClassroomRepository } from './_classroom.js'; +import { + generateWorkspaceToken, + getWorkspaceRepository, + hashWorkspaceToken, + parseAuthorizationToken +} from './_workspace.js'; + +export const ADMIN_TOKEN_ENV = 'INTAKE_ADMIN_TOKEN'; +export const ADMIN_PREVIEW_TTL_MS = 10 * 60 * 1000; +export const ADMIN_MAX_PURGE_CANDIDATES = 200; +export const ADMIN_MIN_IDLE_DAYS = 1; +export const ADMIN_MAX_IDLE_DAYS = 3650; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PREVIEW_VERSION = 1; + +function connectionString() { + return process.env.DATABASE_URL || process.env.POSTGRES_URL || process.env.NEON_DATABASE_URL || ''; +} + +function configuredAdminToken() { + return process.env[ADMIN_TOKEN_ENV] || ''; +} + +function headers(res) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); +} + +function send(res, status, body) { + headers(res); + return res.status(status).json(body); +} + +function methodNotAllowed(res, allow) { + res.setHeader('Allow', allow); + return send(res, 405, { error: 'Method not allowed.' }); +} + +function validConfiguredAdminToken(token) { + return Boolean(parseAuthorizationToken(`Bearer ${token}`)); +} + +/** + * Constant-time comparison for the environment admin credential. + * + * @param {unknown} authorization Authorization header. + * @param {string} configured Server-configured admin token. + * @returns {boolean} Whether the request carries the configured token. + */ +export function isAdminAuthorized(authorization, configured) { + if (!validConfiguredAdminToken(configured)) return false; + const supplied = parseAuthorizationToken(authorization); + if (!supplied) return false; + const left = Buffer.from(hashWorkspaceToken(supplied), 'hex'); + const right = Buffer.from(hashWorkspaceToken(configured), 'hex'); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** @param {unknown} value Candidate UUID. @returns {boolean} Whether valid. */ +export function validateAdminPublicId(value) { + return typeof value === 'string' && UUID_PATTERN.test(value); +} + +/** @param {unknown} value Candidate idle threshold. @returns {number|null} Canonical day count. */ +export function normalizeAdminIdleDays(value) { + const days = Number(value); + return Number.isInteger(days) && days >= ADMIN_MIN_IDLE_DAYS && days <= ADMIN_MAX_IDLE_DAYS + ? days + : null; +} + +function toIso(value) { + if (!value) return null; + const parsed = value instanceof Date ? value : new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function calculateIdleDays(lastActivityAt, nowMs) { + const parsed = lastActivityAt ? new Date(lastActivityAt).getTime() : NaN; + if (!Number.isFinite(parsed)) return null; + return Math.max(0, Math.floor((nowMs - parsed) / 86400000)); +} + +function normalizeWorkspaceList(value) { + if (!Array.isArray(value)) return []; + return value + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: String(item.id || ''), + label: String(item.label || ''), + kind: String(item.kind || '') + })) + .filter(item => validateAdminPublicId(item.id)); +} + +function mapClassInventory(row, nowMs) { + const revokedAt = toIso(row.revokedAt); + const expiresAt = toIso(row.expiresAt); + const lastActivityAt = toIso(row.lastActivityAt || row.updatedAt || row.createdAt); + const expired = expiresAt ? new Date(expiresAt).getTime() <= nowMs : false; + const status = revokedAt ? 'revoked' : expired ? 'expired' : 'active'; + const exercise = row.exerciseId + ? { + id: String(row.exerciseId), + status: String(row.exerciseStatus || ''), + currentStageId: row.currentStageId ? String(row.currentStageId) : null, + stagePhase: row.stagePhase ? String(row.stagePhase) : null, + updatedAt: toIso(row.exerciseUpdatedAt) + } + : null; + return { + id: String(row.id), + title: String(row.title || 'Untitled class'), + status, + joinsEnabled: Boolean(row.joinsEnabled), + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + expiresAt, + revokedAt, + lastActivityAt, + idleDays: calculateIdleDays(lastActivityAt, nowMs), + participantCount: Number(row.participantCount || 0), + workspaceCount: Number(row.workspaceCount || 0), + presenceCount: Number(row.presenceCount || 0), + recentPresenceCount: Number(row.recentPresenceCount || 0), + coachingCount: Number(row.coachingCount || 0), + exerciseCount: Number(row.exerciseCount || 0), + checkpointCount: Number(row.checkpointCount || 0), + releaseCount: Number(row.releaseCount || 0), + workspaces: normalizeWorkspaceList(row.workspaces), + exercise + }; +} + +function mapWorkspaceInventory(row, nowMs) { + const expiresAt = toIso(row.expiresAt); + const lastActivityAt = toIso(row.lastActivityAt || row.updatedAt || row.createdAt); + const expired = expiresAt ? new Date(expiresAt).getTime() <= nowMs : false; + return { + id: String(row.id), + teamName: String(row.teamName || 'Shared intake'), + status: expired ? 'expired' : 'active', + createdAt: toIso(row.createdAt), + updatedAt: toIso(row.updatedAt), + expiresAt, + lastActivityAt, + idleDays: calculateIdleDays(lastActivityAt, nowMs), + participantCount: Number(row.participantCount || 0), + recentPresenceCount: Number(row.recentPresenceCount || 0), + capabilityCount: Number(row.capabilityCount || 0), + classOwned: Boolean(row.classOwned), + classroom: row.classId + ? { + id: String(row.classId), + title: String(row.classTitle || 'Untitled class'), + workspaceId: row.classroomWorkspaceId ? String(row.classroomWorkspaceId) : null, + workspaceKind: row.workspaceKind ? String(row.workspaceKind) : null, + workspaceLabel: row.workspaceLabel ? String(row.workspaceLabel) : null + } + : null + }; +} + +function fingerprintItem(item) { + const stable = item.classOwned === undefined + ? { + id: item.id, + title: item.title, + status: item.status, + joinsEnabled: item.joinsEnabled, + updatedAt: item.updatedAt, + expiresAt: item.expiresAt, + revokedAt: item.revokedAt, + lastActivityAt: item.lastActivityAt, + participantCount: item.participantCount, + workspaceCount: item.workspaceCount, + presenceCount: item.presenceCount, + recentPresenceCount: item.recentPresenceCount, + coachingCount: item.coachingCount, + exerciseCount: item.exerciseCount, + checkpointCount: item.checkpointCount, + releaseCount: item.releaseCount, + workspaces: item.workspaces, + exercise: item.exercise + } + : { + id: item.id, + teamName: item.teamName, + status: item.status, + updatedAt: item.updatedAt, + expiresAt: item.expiresAt, + lastActivityAt: item.lastActivityAt, + participantCount: item.participantCount, + recentPresenceCount: item.recentPresenceCount, + capabilityCount: item.capabilityCount, + classOwned: item.classOwned, + classroom: item.classroom + }; + return createHash('sha256').update(JSON.stringify(stable)).digest('hex'); +} + +function cutoffForDays(days, nowMs) { + return new Date(nowMs - (days * 86400000)).toISOString(); +} + +function classEligible(item, { mode, cutoffAt }) { + if (item.status === 'revoked' || item.status === 'expired') return true; + if (mode !== 'bulk' || !cutoffAt || !item.lastActivityAt) return false; + return new Date(item.lastActivityAt).getTime() <= new Date(cutoffAt).getTime(); +} + +function workspaceEligible(item, { cutoffAt }) { + if (item.classOwned) return false; + if (item.status === 'expired') return true; + if (!cutoffAt || !item.lastActivityAt) return false; + return new Date(item.lastActivityAt).getTime() <= new Date(cutoffAt).getTime(); +} + +/** + * Build a deletion preview from a current inventory. + * + * @param {object} inventory Current inventory. + * @param {object} request Preview request. + * @param {number} nowMs Current epoch milliseconds. + * @returns {object|null} Preview plan or null when invalid. + */ +export function buildAdminPurgePreview(inventory, request, nowMs = Date.now()) { + const scope = request?.scope; + const mode = request?.mode === 'single' ? 'single' : request?.mode === 'bulk' ? 'bulk' : null; + if (!['classes', 'workspaces'].includes(scope) || !mode) return null; + + const idleDays = normalizeAdminIdleDays(request?.idleDays); + if (scope === 'workspaces' && idleDays === null) return null; + if (scope === 'classes' && mode === 'bulk' && idleDays === null) return null; + + const id = request?.id === undefined || request?.id === null || request?.id === '' + ? null + : String(request.id); + if (mode === 'single' && !validateAdminPublicId(id)) return null; + + const cutoffAt = idleDays === null ? null : cutoffForDays(idleDays, nowMs); + const source = scope === 'classes' ? inventory.classes : inventory.workspaces; + let candidates = Array.isArray(source) ? [...source] : []; + + if (mode === 'single') { + candidates = candidates.filter(item => item.id === id); + } + + candidates = candidates.filter(item => ( + scope === 'classes' + ? classEligible(item, { mode, cutoffAt }) + : workspaceEligible(item, { cutoffAt }) + )); + + const truncated = candidates.length > ADMIN_MAX_PURGE_CANDIDATES; + const items = candidates.slice(0, ADMIN_MAX_PURGE_CANDIDATES).map(item => ({ + ...item, + fingerprint: fingerprintItem(item) + })); + + return { + scope, + mode, + idleDays, + cutoffAt, + generatedAt: new Date(nowMs).toISOString(), + truncated, + items + }; +} + +function previewPayload(plan, nowMs) { + return { + version: PREVIEW_VERSION, + scope: plan.scope, + mode: plan.mode, + idleDays: plan.idleDays, + cutoffAt: plan.cutoffAt, + generatedAt: plan.generatedAt, + expiresAt: new Date(nowMs + ADMIN_PREVIEW_TTL_MS).toISOString(), + items: plan.items.map(item => ({ id: item.id, fingerprint: item.fingerprint })) + }; +} + +function encodePreview(payload, adminToken) { + const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); + const signature = createHmac('sha256', adminToken).update(body).digest('base64url'); + return `${body}.${signature}`; +} + +function decodePreview(token, adminToken, nowMs) { + if (typeof token !== 'string' || token.length > 50000) return null; + const [body, signature, extra] = token.split('.'); + if (!body || !signature || extra !== undefined) return null; + const expected = createHmac('sha256', adminToken).update(body).digest(); + let supplied; + try { + supplied = Buffer.from(signature, 'base64url'); + } catch { + return null; + } + if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return null; + let payload; + try { + payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); + } catch { + return null; + } + if ( + payload?.version !== PREVIEW_VERSION + || !['classes', 'workspaces'].includes(payload.scope) + || !['single', 'bulk'].includes(payload.mode) + || !Array.isArray(payload.items) + || payload.items.length < 1 + || payload.items.length > ADMIN_MAX_PURGE_CANDIDATES + || !payload.items.every(item => validateAdminPublicId(item?.id) && /^[0-9a-f]{64}$/i.test(item?.fingerprint || '')) + ) { + return null; + } + const expiresAt = new Date(payload.expiresAt).getTime(); + if (!Number.isFinite(expiresAt) || expiresAt < nowMs) return null; + return payload; +} + +function plansMatch(payload, currentPlan) { + if (!currentPlan || currentPlan.items.length !== payload.items.length) return false; + const expected = new Map(payload.items.map(item => [item.id, item.fingerprint])); + return currentPlan.items.every(item => expected.get(item.id) === item.fingerprint); +} + +let adminRepositoryPromise; + +/** + * Initialize the Admin repository after the current Classroom/collaboration schema. + * + * @returns {Promise} Admin repository. + */ +export async function getAdminRepository() { + if (!adminRepositoryPromise) adminRepositoryPromise = initializeAdminRepository(); + return adminRepositoryPromise; +} + +async function initializeAdminRepository() { + const connection = connectionString(); + if (!connection) throw new Error('Database is not configured'); + + await getWorkspaceRepository(); + await getClassroomRepository(); + + const { neon } = await import('@neondatabase/serverless'); + const sql = neon(connection); + + async function listInventory(nowMs = Date.now()) { + const [classRows, workspaceRows] = await Promise.all([ + sql`SELECT + c.public_id AS id, + c.title, + c.joins_enabled AS "joinsEnabled", + c.created_at AS "createdAt", + c.updated_at AS "updatedAt", + c.expires_at AS "expiresAt", + c.revoked_at AS "revokedAt", + GREATEST( + c.updated_at, + COALESCE((SELECT MAX(cp.updated_at) FROM classroom_participants cp WHERE cp.class_id = c.id), c.created_at), + COALESCE((SELECT MAX(cw.updated_at) FROM classroom_workspaces cw WHERE cw.class_id = c.id), c.created_at), + COALESCE(( + SELECT MAX(w.updated_at) + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at), + COALESCE(( + SELECT MAX(p.last_seen_at) + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at), + COALESCE(( + SELECT MAX(p.last_active_at) + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at), + COALESCE((SELECT MAX(e.updated_at) FROM classroom_exercises e WHERE e.class_id = c.id), c.created_at), + COALESCE((SELECT MAX(f.updated_at) FROM classroom_coaching_feedback f WHERE f.class_id = c.id), c.created_at) + ) AS "lastActivityAt", + (SELECT COUNT(*)::int FROM classroom_participants cp WHERE cp.class_id = c.id AND cp.revoked_at IS NULL) AS "participantCount", + (SELECT COUNT(*)::int FROM classroom_workspaces cw WHERE cw.class_id = c.id) AS "workspaceCount", + (SELECT COUNT(*)::int + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id) AS "presenceCount", + (SELECT COUNT(*)::int + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id + AND p.last_seen_at >= NOW() - 5 * INTERVAL '1 minute') AS "recentPresenceCount", + (SELECT COUNT(*)::int FROM classroom_coaching_feedback f WHERE f.class_id = c.id) AS "coachingCount", + (SELECT COUNT(*)::int FROM classroom_exercises e WHERE e.class_id = c.id) AS "exerciseCount", + (SELECT COUNT(*)::int FROM classroom_exercise_checkpoints k WHERE k.class_id = c.id) AS "checkpointCount", + (SELECT COUNT(*)::int + FROM classroom_exercise_releases r + JOIN classroom_exercises e ON e.id = r.exercise_id + WHERE e.class_id = c.id) AS "releaseCount", + COALESCE(( + SELECT jsonb_agg( + jsonb_build_object('id', cw.public_id, 'label', cw.label, 'kind', cw.workspace_kind) + ORDER BY cw.created_at, cw.public_id + ) + FROM classroom_workspaces cw + WHERE cw.class_id = c.id + ), '[]'::jsonb) AS workspaces, + current_exercise.public_id AS "exerciseId", + current_exercise.status AS "exerciseStatus", + current_exercise.current_stage_id AS "currentStageId", + current_exercise.stage_phase AS "stagePhase", + current_exercise.updated_at AS "exerciseUpdatedAt" + FROM classroom_classes c + LEFT JOIN LATERAL ( + SELECT e.public_id, e.status, e.current_stage_id, e.stage_phase, e.updated_at + FROM classroom_exercises e + WHERE e.class_id = c.id AND e.status <> 'completed' + ORDER BY e.created_at DESC, e.id DESC + LIMIT 1 + ) current_exercise ON TRUE + ORDER BY c.updated_at DESC, c.created_at DESC, c.public_id`, + sql`SELECT + w.public_id AS id, + COALESCE(w.team_name, 'Shared intake') AS "teamName", + w.created_at AS "createdAt", + w.updated_at AS "updatedAt", + w.expires_at AS "expiresAt", + GREATEST( + w.updated_at, + COALESCE((SELECT MAX(p.last_seen_at) FROM collaboration_participants p WHERE p.workspace_id = w.id), w.created_at), + COALESCE((SELECT MAX(p.last_active_at) FROM collaboration_participants p WHERE p.workspace_id = w.id), w.created_at) + ) AS "lastActivityAt", + (SELECT COUNT(*)::int FROM collaboration_participants p WHERE p.workspace_id = w.id) AS "participantCount", + (SELECT COUNT(*)::int + FROM collaboration_participants p + WHERE p.workspace_id = w.id + AND p.last_seen_at >= NOW() - 5 * INTERVAL '1 minute') AS "recentPresenceCount", + (SELECT COUNT(*)::int FROM collaboration_workspace_capabilities cap WHERE cap.workspace_id = w.id) AS "capabilityCount", + (cw.workspace_id IS NOT NULL) AS "classOwned", + c.public_id AS "classId", + c.title AS "classTitle", + cw.public_id AS "classroomWorkspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM collaboration_workspaces w + LEFT JOIN classroom_workspaces cw ON cw.workspace_id = w.id + LEFT JOIN classroom_classes c ON c.id = cw.class_id + ORDER BY w.updated_at DESC, w.created_at DESC, w.public_id` + ]); + + return { + generatedAt: new Date(nowMs).toISOString(), + classes: classRows.map(row => mapClassInventory(row, nowMs)), + workspaces: workspaceRows.map(row => mapWorkspaceInventory(row, nowMs)) + }; + } + + async function revokeClassById(publicId) { + const rows = await sql`WITH target AS ( + UPDATE classroom_classes + SET revoked_at = COALESCE(revoked_at, NOW()), + joins_enabled = FALSE, + updated_at = NOW() + WHERE public_id = ${publicId}::uuid + AND revoked_at IS NULL + RETURNING id, public_id + ), + marked AS ( + UPDATE classroom_workspaces cw + SET revoked_at = COALESCE(cw.revoked_at, NOW()), + updated_at = NOW() + WHERE cw.class_id = (SELECT id FROM target) + RETURNING cw.workspace_id + ), + expired AS ( + UPDATE collaboration_workspaces w + SET expires_at = LEAST(w.expires_at, NOW()), + updated_at = NOW() + WHERE w.id IN (SELECT workspace_id FROM marked) + RETURNING w.id + ) + SELECT public_id AS id FROM target`; + return rows[0] || null; + } + + async function rotateInstructorById(publicId, nextHash) { + const rows = await sql`UPDATE classroom_classes + SET instructor_token_hash = ${nextHash}, + updated_at = NOW() + WHERE public_id = ${publicId}::uuid + AND revoked_at IS NULL + AND expires_at > NOW() + RETURNING + public_id AS id, + title, + joins_enabled AS "joinsEnabled", + expires_at AS "expiresAt"`; + return rows[0] || null; + } + + async function purgeClasses(ids, { mode, cutoffAt }) { + const rows = await sql`WITH requested AS ( + SELECT value::uuid AS public_id + FROM jsonb_array_elements_text(${JSON.stringify(ids)}::jsonb) + ), + current AS ( + SELECT + c.id, + c.public_id, + (c.revoked_at IS NOT NULL OR c.expires_at <= NOW()) AS terminal, + GREATEST( + c.updated_at, + COALESCE((SELECT MAX(cp.updated_at) FROM classroom_participants cp WHERE cp.class_id = c.id), c.created_at), + COALESCE((SELECT MAX(cw.updated_at) FROM classroom_workspaces cw WHERE cw.class_id = c.id), c.created_at), + COALESCE(( + SELECT MAX(w.updated_at) + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at), + COALESCE(( + SELECT MAX(p.last_seen_at) + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at), + COALESCE(( + SELECT MAX(p.last_active_at) + FROM classroom_workspaces cw + JOIN collaboration_participants p ON p.workspace_id = cw.workspace_id + WHERE cw.class_id = c.id + ), c.created_at) + ) AS last_activity + FROM classroom_classes c + JOIN requested r ON r.public_id = c.public_id + ), + eligible AS ( + SELECT * + FROM current + WHERE terminal = TRUE + OR ( + ${mode === 'bulk'} + AND ${cutoffAt || null}::timestamptz IS NOT NULL + AND last_activity <= ${cutoffAt || null}::timestamptz + ) + ), + guard AS ( + SELECT + (SELECT COUNT(*) FROM requested) > 0 + AND (SELECT COUNT(*) FROM requested) = (SELECT COUNT(*) FROM eligible) AS ok + ), + workspace_ids AS ( + SELECT cw.workspace_id + FROM classroom_workspaces cw + JOIN eligible e ON e.id = cw.class_id + WHERE (SELECT ok FROM guard) + ), + deleted_workspaces AS ( + DELETE FROM collaboration_workspaces w + WHERE (SELECT ok FROM guard) + AND w.id IN (SELECT workspace_id FROM workspace_ids) + RETURNING w.id + ), + deleted_classes AS ( + DELETE FROM classroom_classes c + WHERE (SELECT ok FROM guard) + AND c.id IN (SELECT id FROM eligible) + AND ( + (SELECT COUNT(*) FROM workspace_ids) = 0 + OR (SELECT COUNT(*) FROM workspace_ids) = (SELECT COUNT(*) FROM deleted_workspaces) + ) + RETURNING c.public_id + ) + SELECT + (SELECT ok FROM guard) AS ok, + COALESCE((SELECT jsonb_agg(public_id ORDER BY public_id) FROM deleted_classes), '[]'::jsonb) AS "purgedIds", + (SELECT COUNT(*)::int FROM deleted_workspaces) AS "workspaceCount"`; + return rows[0] || { ok: false, purgedIds: [], workspaceCount: 0 }; + } + + async function purgeStandaloneWorkspaces(ids, { cutoffAt }) { + const rows = await sql`WITH requested AS ( + SELECT value::uuid AS public_id + FROM jsonb_array_elements_text(${JSON.stringify(ids)}::jsonb) + ), + current AS ( + SELECT + w.id, + w.public_id, + (w.expires_at <= NOW()) AS terminal, + GREATEST( + w.updated_at, + COALESCE((SELECT MAX(p.last_seen_at) FROM collaboration_participants p WHERE p.workspace_id = w.id), w.created_at), + COALESCE((SELECT MAX(p.last_active_at) FROM collaboration_participants p WHERE p.workspace_id = w.id), w.created_at) + ) AS last_activity + FROM collaboration_workspaces w + JOIN requested r ON r.public_id = w.public_id + WHERE NOT EXISTS ( + SELECT 1 FROM classroom_workspaces cw WHERE cw.workspace_id = w.id + ) + ), + eligible AS ( + SELECT * + FROM current + WHERE terminal = TRUE + OR ( + ${cutoffAt || null}::timestamptz IS NOT NULL + AND last_activity <= ${cutoffAt || null}::timestamptz + ) + ), + guard AS ( + SELECT + (SELECT COUNT(*) FROM requested) > 0 + AND (SELECT COUNT(*) FROM requested) = (SELECT COUNT(*) FROM eligible) AS ok + ), + deleted AS ( + DELETE FROM collaboration_workspaces w + WHERE (SELECT ok FROM guard) + AND w.id IN (SELECT id FROM eligible) + RETURNING w.public_id + ) + SELECT + (SELECT ok FROM guard) AS ok, + COALESCE((SELECT jsonb_agg(public_id ORDER BY public_id) FROM deleted), '[]'::jsonb) AS "purgedIds"`; + return rows[0] || { ok: false, purgedIds: [] }; + } + + return { + listInventory, + revokeClassById, + rotateInstructorById, + purgeClasses, + purgeStandaloneWorkspaces + }; +} + +function publicPreviewItem(item) { + const { fingerprint: _fingerprint, ...publicItem } = item; + return publicItem; +} + +/** + * Create the single Administration / Maintenance HTTP handler. + * + * @param {object} [dependencies] Test/runtime dependencies. + * @returns {Function} Vercel handler. + */ +export function adminHandler({ + getRepository = getAdminRepository, + getAdminToken = configuredAdminToken, + tokenFactory = generateWorkspaceToken, + now = () => Date.now() +} = {}) { + return async (req, res) => { + const adminToken = getAdminToken(); + if (!validConfiguredAdminToken(adminToken)) { + return send(res, 503, { error: 'Administration is not configured.' }); + } + if (!isAdminAuthorized(req.headers?.authorization, adminToken)) { + return send(res, 401, { error: 'Administration authorization required.' }); + } + if (!['GET', 'POST'].includes(req.method)) { + return methodNotAllowed(res, 'GET, POST'); + } + + try { + const repository = await getRepository(); + + if (req.method === 'GET') { + const inventory = await repository.listInventory(now()); + return send(res, 200, inventory); + } + + const action = req.body?.action; + if (action === 'revoke-class') { + const classId = req.body?.classId; + if (!validateAdminPublicId(classId)) { + return send(res, 400, { error: 'Invalid class identifier.' }); + } + const revoked = await repository.revokeClassById(classId); + return revoked + ? send(res, 200, { revoked: true, classId: String(revoked.id) }) + : send(res, 404, { error: 'Active class not found.' }); + } + + if (action === 'rotate-instructor') { + const classId = req.body?.classId; + if (!validateAdminPublicId(classId)) { + return send(res, 400, { error: 'Invalid class identifier.' }); + } + const instructorToken = tokenFactory(); + const classroom = await repository.rotateInstructorById(classId, hashWorkspaceToken(instructorToken)); + if (!classroom) { + return send(res, 404, { error: 'Active class not found.' }); + } + return send(res, 200, { + classroom, + instructorToken + }); + } + + if (action === 'preview-purge') { + const inventory = await repository.listInventory(now()); + const plan = buildAdminPurgePreview(inventory, req.body, now()); + if (!plan) { + return send(res, 400, { error: 'Invalid purge preview request.' }); + } + if (!plan.items.length) { + return send(res, 200, { + plan: { + ...plan, + items: [] + }, + previewToken: null, + expiresAt: null + }); + } + const payload = previewPayload(plan, now()); + return send(res, 200, { + plan: { + ...plan, + items: plan.items.map(publicPreviewItem) + }, + previewToken: encodePreview(payload, adminToken), + expiresAt: payload.expiresAt + }); + } + + if (action === 'commit-purge') { + const payload = decodePreview(req.body?.previewToken, adminToken, now()); + if (!payload) { + return send(res, 400, { error: 'Purge preview is invalid or expired.' }); + } + + const inventory = await repository.listInventory(now()); + const currentPlan = buildAdminPurgePreview(inventory, { + scope: payload.scope, + mode: payload.mode, + idleDays: payload.idleDays, + ...(payload.mode === 'single' ? { id: payload.items[0]?.id } : {}) + }, new Date(payload.generatedAt).getTime()); + + if (!plansMatch(payload, currentPlan)) { + return send(res, 409, { error: 'Maintenance data changed. Refresh the preview before deleting.' }); + } + + const ids = payload.items.map(item => item.id); + const result = payload.scope === 'classes' + ? await repository.purgeClasses(ids, { mode: payload.mode, cutoffAt: payload.cutoffAt }) + : await repository.purgeStandaloneWorkspaces(ids, { cutoffAt: payload.cutoffAt }); + + const purgedIds = Array.isArray(result?.purgedIds) + ? result.purgedIds.map(String) + : []; + if (!result?.ok || purgedIds.length !== ids.length) { + return send(res, 409, { error: 'Maintenance data changed. No purge was committed; refresh the preview.' }); + } + + return send(res, 200, { + purged: true, + scope: payload.scope, + ids: purgedIds, + ...(payload.scope === 'classes' ? { workspaceCount: Number(result.workspaceCount || 0) } : {}) + }); + } + + return send(res, 400, { error: 'Unknown administration action.' }); + } catch (_error) { + return send(res, 500, { error: 'Unable to complete administration request.' }); + } + }; +} From b3bbb41d5235e2803156d301e8c1a438a6f64b5c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:12:54 -0400 Subject: [PATCH 03/29] Expose single Admin maintenance function --- api/admin.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/admin.js diff --git a/api/admin.js b/api/admin.js new file mode 100644 index 0000000..4e6fe7a --- /dev/null +++ b/api/admin.js @@ -0,0 +1,7 @@ +/** + * @module api/admin-entry + * @description Single Vercel Serverless Function entrypoint for Administration / Maintenance. + */ +import { adminHandler } from './_admin.js'; + +export default adminHandler(); From c0c0428adf365892353241601739d5520888302d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:13:23 -0400 Subject: [PATCH 04/29] Test Admin maintenance authorization and purge previews --- tests/admin-api.unit.test.mjs | 447 ++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 tests/admin-api.unit.test.mjs diff --git a/tests/admin-api.unit.test.mjs b/tests/admin-api.unit.test.mjs new file mode 100644 index 0000000..0009467 --- /dev/null +++ b/tests/admin-api.unit.test.mjs @@ -0,0 +1,447 @@ +/** + * Administration / Maintenance API unit coverage. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + ADMIN_TOKEN_ENV, + adminHandler, + buildAdminPurgePreview, + isAdminAuthorized, + normalizeAdminIdleDays, + validateAdminPublicId +} from '../api/_admin.js'; +import { hashWorkspaceToken } from '../api/_workspace.js'; +import { response, tokenFactory } from './helpers/classroom-test-repositories.mjs'; + +const ADMIN = 'A'.repeat(43); +const CLASS_ACTIVE = '11111111-1111-4111-8111-111111111111'; +const CLASS_EXPIRED = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_STALE = '33333333-3333-4333-8333-333333333333'; +const WORKSPACE_CLASS = '44444444-4444-4444-8444-444444444444'; + +function request(method, { token = ADMIN, body = null } = {}) { + return { + method, + headers: token ? { authorization: `Bearer ${token}` } : {}, + body + }; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function inventoryFixture() { + return { + generatedAt: '2026-10-08T18:00:00.000Z', + classes: [ + { + id: CLASS_ACTIVE, + title: 'Active class', + status: 'active', + joinsEnabled: true, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2026-11-01T00:00:00.000Z', + revokedAt: null, + lastActivityAt: '2026-10-08T17:59:00.000Z', + idleDays: 0, + participantCount: 2, + workspaceCount: 1, + presenceCount: 2, + recentPresenceCount: 1, + coachingCount: 1, + exerciseCount: 1, + checkpointCount: 0, + releaseCount: 1, + workspaces: [{ id: WORKSPACE_CLASS, label: 'Team A', kind: 'group' }], + exercise: { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + updatedAt: '2026-10-08T17:58:00.000Z' + } + }, + { + id: CLASS_EXPIRED, + title: 'Expired class', + status: 'expired', + joinsEnabled: false, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:00.000Z', + expiresAt: '2026-09-01T00:00:00.000Z', + revokedAt: null, + lastActivityAt: '2026-08-20T00:00:00.000Z', + idleDays: 49, + participantCount: 1, + workspaceCount: 0, + presenceCount: 0, + recentPresenceCount: 0, + coachingCount: 0, + exerciseCount: 0, + checkpointCount: 0, + releaseCount: 0, + workspaces: [], + exercise: null + } + ], + workspaces: [ + { + id: WORKSPACE_STALE, + teamName: 'Old shared intake', + status: 'active', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-02T00:00:00.000Z', + expiresAt: '2026-12-01T00:00:00.000Z', + lastActivityAt: '2026-07-02T00:00:00.000Z', + idleDays: 98, + participantCount: 2, + recentPresenceCount: 0, + capabilityCount: 0, + classOwned: false, + classroom: null + }, + { + id: WORKSPACE_CLASS, + teamName: 'Team A', + status: 'active', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2026-11-01T00:00:00.000Z', + lastActivityAt: '2026-10-08T17:59:00.000Z', + idleDays: 0, + participantCount: 2, + recentPresenceCount: 1, + capabilityCount: 2, + classOwned: true, + classroom: { + id: CLASS_ACTIVE, + title: 'Active class', + workspaceId: WORKSPACE_CLASS, + workspaceKind: 'group', + workspaceLabel: 'Team A' + } + } + ] + }; +} + +function createRepository(initialInventory = inventoryFixture()) { + let inventory = clone(initialInventory); + const calls = { + revoked: [], + rotated: [], + purgedClasses: [], + purgedWorkspaces: [] + }; + + return { + calls, + setInventory(next) { + inventory = clone(next); + }, + async listInventory() { + return clone(inventory); + }, + async revokeClassById(id) { + calls.revoked.push(id); + const item = inventory.classes.find(candidate => candidate.id === id); + if (!item || item.status === 'revoked') return null; + item.status = 'revoked'; + item.revokedAt = '2026-10-08T18:00:00.000Z'; + item.joinsEnabled = false; + return { id }; + }, + async rotateInstructorById(id, nextHash) { + calls.rotated.push({ id, nextHash }); + const item = inventory.classes.find(candidate => candidate.id === id); + if (!item || item.status !== 'active') return null; + return { id, title: item.title, joinsEnabled: item.joinsEnabled, expiresAt: item.expiresAt }; + }, + async purgeClasses(ids) { + calls.purgedClasses.push([...ids]); + inventory.classes = inventory.classes.filter(item => !ids.includes(item.id)); + return { ok: true, purgedIds: [...ids], workspaceCount: 0 }; + }, + async purgeStandaloneWorkspaces(ids) { + calls.purgedWorkspaces.push([...ids]); + inventory.workspaces = inventory.workspaces.filter(item => !ids.includes(item.id)); + return { ok: true, purgedIds: [...ids] }; + } + }; +} + +test('admin identifiers, thresholds, and credentials validate conservatively', () => { + assert.equal(validateAdminPublicId(CLASS_ACTIVE), true); + assert.equal(validateAdminPublicId('not-a-uuid'), false); + assert.equal(normalizeAdminIdleDays(30), 30); + assert.equal(normalizeAdminIdleDays('90'), 90); + assert.equal(normalizeAdminIdleDays(0), null); + assert.equal(normalizeAdminIdleDays(3651), null); + assert.equal(isAdminAuthorized(`Bearer ${ADMIN}`, ADMIN), true); + assert.equal(isAdminAuthorized(`Bearer ${'B'.repeat(43)}`, ADMIN), false); + assert.equal(isAdminAuthorized(undefined, ADMIN), false); +}); + +test('admin handler fails closed when the environment credential is absent or malformed', async () => { + const missing = response(); + await adminHandler({ + getRepository: async () => createRepository(), + getAdminToken: () => '' + })(request('GET'), missing); + + assert.equal(missing.statusCode, 503); + assert.deepEqual(missing.body, { error: 'Administration is not configured.' }); + assert.equal(missing.headers['Cache-Control'], 'no-store'); + assert.equal(missing.headers['Referrer-Policy'], 'no-referrer'); + + const malformed = response(); + await adminHandler({ + getRepository: async () => createRepository(), + getAdminToken: () => 'short' + })(request('GET'), malformed); + assert.equal(malformed.statusCode, 503); +}); + +test('admin handler rejects missing or incorrect authorization without exposing inventory', async () => { + let reads = 0; + const handler = adminHandler({ + getRepository: async () => ({ + async listInventory() { + reads += 1; + return inventoryFixture(); + } + }), + getAdminToken: () => ADMIN + }); + + const missing = response(); + await handler(request('GET', { token: null }), missing); + assert.equal(missing.statusCode, 401); + + const incorrect = response(); + await handler(request('GET', { token: 'B'.repeat(43) }), incorrect); + assert.equal(incorrect.statusCode, 401); + assert.equal(reads, 0); +}); + +test('admin inventory returns lifecycle metadata without bearer capabilities', async () => { + const repository = createRepository(); + const res = response(); + await adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + now: () => Date.parse('2026-10-08T18:00:00.000Z') + })(request('GET'), res); + + assert.equal(res.statusCode, 200); + assert.equal(res.body.classes.length, 2); + assert.equal(res.body.workspaces.length, 2); + assert.equal(JSON.stringify(res.body).includes(ADMIN), false); + assert.equal(JSON.stringify(res.body).includes('instructorToken'), false); + assert.equal(res.headers['Cache-Control'], 'no-store'); + assert.equal(res.headers['Referrer-Policy'], 'no-referrer'); +}); + +test('purge preview protects a recently active class even when its snapshot is old', () => { + const plan = buildAdminPurgePreview( + inventoryFixture(), + { scope: 'classes', mode: 'bulk', idleDays: 30 }, + Date.parse('2026-10-08T18:00:00.000Z') + ); + + assert.deepEqual(plan.items.map(item => item.id), [CLASS_EXPIRED]); + assert.equal(plan.items.some(item => item.id === CLASS_ACTIVE), false); +}); + +test('purge preview never includes class-owned collaboration workspaces', () => { + const plan = buildAdminPurgePreview( + inventoryFixture(), + { scope: 'workspaces', mode: 'bulk', idleDays: 30 }, + Date.parse('2026-10-08T18:00:00.000Z') + ); + + assert.deepEqual(plan.items.map(item => item.id), [WORKSPACE_STALE]); + assert.equal(plan.items.some(item => item.id === WORKSPACE_CLASS), false); +}); + +test('preview then commit purges exactly the signed stale workspace plan', async () => { + const repository = createRepository(); + const nowMs = Date.parse('2026-10-08T18:00:00.000Z'); + const handler = adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + now: () => nowMs + }); + + const preview = response(); + await handler(request('POST', { + body: { + action: 'preview-purge', + scope: 'workspaces', + mode: 'bulk', + idleDays: 30 + } + }), preview); + + assert.equal(preview.statusCode, 200); + assert.equal(preview.body.plan.items.length, 1); + assert.equal(preview.body.plan.items[0].id, WORKSPACE_STALE); + assert.equal(typeof preview.body.previewToken, 'string'); + assert.equal('fingerprint' in preview.body.plan.items[0], false); + + const commit = response(); + await handler(request('POST', { + body: { + action: 'commit-purge', + previewToken: preview.body.previewToken + } + }), commit); + + assert.equal(commit.statusCode, 200); + assert.deepEqual(commit.body.ids, [WORKSPACE_STALE]); + assert.deepEqual(repository.calls.purgedWorkspaces, [[WORKSPACE_STALE]]); +}); + +test('changed maintenance data invalidates a signed purge preview before deletion', async () => { + const repository = createRepository(); + const nowMs = Date.parse('2026-10-08T18:00:00.000Z'); + const handler = adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + now: () => nowMs + }); + + const preview = response(); + await handler(request('POST', { + body: { + action: 'preview-purge', + scope: 'workspaces', + mode: 'bulk', + idleDays: 30 + } + }), preview); + assert.equal(preview.statusCode, 200); + + const changed = inventoryFixture(); + changed.workspaces[0].lastActivityAt = '2026-10-08T17:59:30.000Z'; + changed.workspaces[0].idleDays = 0; + changed.workspaces[0].recentPresenceCount = 1; + repository.setInventory(changed); + + const commit = response(); + await handler(request('POST', { + body: { + action: 'commit-purge', + previewToken: preview.body.previewToken + } + }), commit); + + assert.equal(commit.statusCode, 409); + assert.deepEqual(repository.calls.purgedWorkspaces, []); +}); + +test('single class purge requires a terminal class', async () => { + const repository = createRepository(); + const handler = adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + now: () => Date.parse('2026-10-08T18:00:00.000Z') + }); + + const active = response(); + await handler(request('POST', { + body: { + action: 'preview-purge', + scope: 'classes', + mode: 'single', + id: CLASS_ACTIVE + } + }), active); + assert.equal(active.statusCode, 200); + assert.equal(active.body.plan.items.length, 0); + assert.equal(active.body.previewToken, null); + + const expired = response(); + await handler(request('POST', { + body: { + action: 'preview-purge', + scope: 'classes', + mode: 'single', + id: CLASS_EXPIRED + } + }), expired); + assert.equal(expired.statusCode, 200); + assert.deepEqual(expired.body.plan.items.map(item => item.id), [CLASS_EXPIRED]); + assert.equal(typeof expired.body.previewToken, 'string'); +}); + +test('admin can revoke a class and rotate active Instructor authority without exposing stored hashes', async () => { + const repository = createRepository(); + const nextToken = 'R'.repeat(43); + const handler = adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + tokenFactory: tokenFactory(['R']) + }); + + const rotate = response(); + await handler(request('POST', { + body: { action: 'rotate-instructor', classId: CLASS_ACTIVE } + }), rotate); + + assert.equal(rotate.statusCode, 200); + assert.equal(rotate.body.instructorToken, nextToken); + assert.equal(repository.calls.rotated[0].nextHash, hashWorkspaceToken(nextToken)); + assert.equal(repository.calls.rotated[0].nextHash.includes(nextToken), false); + + const revoke = response(); + await handler(request('POST', { + body: { action: 'revoke-class', classId: CLASS_ACTIVE } + }), revoke); + + assert.equal(revoke.statusCode, 200); + assert.deepEqual(revoke.body, { revoked: true, classId: CLASS_ACTIVE }); + assert.deepEqual(repository.calls.revoked, [CLASS_ACTIVE]); +}); + +test('admin preview tokens expire and cannot be replayed indefinitely', async () => { + const repository = createRepository(); + let nowMs = Date.parse('2026-10-08T18:00:00.000Z'); + const handler = adminHandler({ + getRepository: async () => repository, + getAdminToken: () => ADMIN, + now: () => nowMs + }); + + const preview = response(); + await handler(request('POST', { + body: { + action: 'preview-purge', + scope: 'workspaces', + mode: 'bulk', + idleDays: 30 + } + }), preview); + assert.equal(preview.statusCode, 200); + + nowMs += 11 * 60 * 1000; + const commit = response(); + await handler(request('POST', { + body: { + action: 'commit-purge', + previewToken: preview.body.previewToken + } + }), commit); + + assert.equal(commit.statusCode, 400); + assert.match(commit.body.error, /invalid or expired/i); + assert.deepEqual(repository.calls.purgedWorkspaces, []); +}); + +test('admin environment variable name stays server-only and explicit', () => { + assert.equal(ADMIN_TOKEN_ENV, 'INTAKE_ADMIN_TOKEN'); +}); From 274bd82f8fbe6ac16bec263b5c90768da18cbedb Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:13:32 -0400 Subject: [PATCH 05/29] Account for single Admin serverless entrypoint --- tests/vercelFunctionBudget.unit.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/vercelFunctionBudget.unit.test.mjs b/tests/vercelFunctionBudget.unit.test.mjs index 74b303f..deb0c4d 100644 --- a/tests/vercelFunctionBudget.unit.test.mjs +++ b/tests/vercelFunctionBudget.unit.test.mjs @@ -71,6 +71,7 @@ test('repository stays below the conservative Vercel Hobby function budget', asy const candidates = await listVercelFunctionCandidates('api'); assert.equal(candidates.length <= VERCEL_HOBBY_FUNCTION_LIMIT, true); assert.deepEqual(candidates, [ + 'api/admin.js', 'api/classroom.js', 'api/protected-case-studies.manifest.js', 'api/workspaces/index.js', From aa573a9ed265e4a3a6bed32acb48ef0d5c4f98cd Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:15:23 -0400 Subject: [PATCH 06/29] Add Administration Maintenance browser controller --- src/adminMaintenance.js | 713 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 src/adminMaintenance.js diff --git a/src/adminMaintenance.js b/src/adminMaintenance.js new file mode 100644 index 0000000..ba751b8 --- /dev/null +++ b/src/adminMaintenance.js @@ -0,0 +1,713 @@ +/** + * @module adminMaintenance + * @description Privileged browser UI for server-authorized lifecycle inventory, recovery, and preview-first cleanup. + * + * Administration is not an Intake experience role. The raw Admin credential is + * retained only in sessionStorage for the current tab after successful server + * authorization. It never enters Intake state, localStorage, exports, summaries, + * URLs, or telemetry. + */ + +export const ADMIN_SESSION_STORAGE_KEY = 'kt-admin-session-v1'; +export const ADMIN_SESSION_VERSION = 1; +export const ADMIN_ENDPOINT = '/api/admin'; + +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +let documentRef = null; +let windowRef = null; +let sessionStorageRef = null; +let fetchRef = null; +let toastRef = null; +let adminToken = ''; +let inventory = { classes: [], workspaces: [] }; +let activePreview = null; +let returnFocus = null; + +function element(id) { + return documentRef?.getElementById(id) || null; +} + +function safeSessionStorage() { + try { + return typeof sessionStorage !== 'undefined' ? sessionStorage : null; + } catch { + return null; + } +} + +function validToken(value) { + return typeof value === 'string' && TOKEN_PATTERN.test(value); +} + +function readSessionToken() { + if (!sessionStorageRef) return ''; + try { + const parsed = JSON.parse(sessionStorageRef.getItem(ADMIN_SESSION_STORAGE_KEY) || 'null'); + if (parsed?.version === ADMIN_SESSION_VERSION && validToken(parsed.token)) { + return parsed.token; + } + if (parsed !== null) sessionStorageRef.removeItem(ADMIN_SESSION_STORAGE_KEY); + } catch { + try { sessionStorageRef.removeItem(ADMIN_SESSION_STORAGE_KEY); } catch {} + } + return ''; +} + +function persistSessionToken(token) { + if (!sessionStorageRef || !validToken(token)) return false; + try { + sessionStorageRef.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify({ + version: ADMIN_SESSION_VERSION, + token + })); + return true; + } catch { + return false; + } +} + +function clearSessionToken() { + adminToken = ''; + try { sessionStorageRef?.removeItem(ADMIN_SESSION_STORAGE_KEY); } catch {} +} + +function setStatus(message = '', state = '') { + const status = element('adminMaintenanceStatus'); + if (!status) return; + status.textContent = message; + status.dataset.state = state; + status.hidden = !message; +} + +function setBusy(busy) { + const dialog = element('adminMaintenanceDialog'); + if (!dialog) return; + dialog.setAttribute('aria-busy', busy ? 'true' : 'false'); + dialog.querySelectorAll('button, input, select').forEach(control => { + if (control.id === 'adminMaintenanceCloseBtn') return; + control.disabled = Boolean(busy); + }); +} + +function showAuth() { + const auth = element('adminMaintenanceAuth'); + const consolePanel = element('adminMaintenanceConsole'); + if (auth) auth.hidden = false; + if (consolePanel) consolePanel.hidden = true; + const input = element('adminMaintenanceToken'); + if (input) { + input.value = ''; + input.focus(); + } +} + +function showConsole() { + const auth = element('adminMaintenanceAuth'); + const consolePanel = element('adminMaintenanceConsole'); + if (auth) auth.hidden = true; + if (consolePanel) consolePanel.hidden = false; +} + +async function requestAdmin(method = 'GET', body = null, token = adminToken) { + if (!validToken(token) || typeof fetchRef !== 'function') { + return { ok: false, status: 401, body: { error: 'Administration authorization required.' } }; + } + const response = await fetchRef(ADMIN_ENDPOINT, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}) + }, + ...(body ? { body: JSON.stringify(body) } : {}) + }); + let payload = {}; + try { + payload = await response.json(); + } catch { + payload = {}; + } + return { ok: response.ok, status: response.status, body: payload }; +} + +function formatTimestamp(value) { + if (!value) return '—'; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return '—'; + try { + return parsed.toLocaleString(); + } catch { + return parsed.toISOString(); + } +} + +function idleLabel(item) { + if (!Number.isFinite(item?.idleDays)) return 'Activity unknown'; + if (item.idleDays === 0) return 'Active today'; + return `${item.idleDays} day${item.idleDays === 1 ? '' : 's'} idle`; +} + +function statusBadge(status) { + const badge = documentRef.createElement('span'); + badge.className = `admin-maintenance__badge admin-maintenance__badge--${status || 'unknown'}`; + badge.textContent = status || 'unknown'; + return badge; +} + +function metaRow(label, value) { + const row = documentRef.createElement('div'); + row.className = 'admin-maintenance__meta-row'; + const term = documentRef.createElement('span'); + term.textContent = label; + const detail = documentRef.createElement('strong'); + detail.textContent = value; + row.append(term, detail); + return row; +} + +function actionButton(label, onClick, { danger = false } = {}) { + const button = documentRef.createElement('button'); + button.type = 'button'; + button.className = danger ? 'btn-secondary admin-maintenance__danger' : 'btn-secondary'; + button.textContent = label; + button.addEventListener('click', onClick); + return button; +} + +function currentIdleDays() { + const value = Number(element('adminMaintenanceIdleDays')?.value || 30); + return Number.isInteger(value) && value > 0 ? value : 30; +} + +function classMatchesFilters(item) { + const query = (element('adminClassSearch')?.value || '').trim().toLowerCase(); + const status = element('adminClassStatusFilter')?.value || 'all'; + if (status !== 'all' && item.status !== status) return false; + if (!query) return true; + return [item.title, item.id, item.exercise?.status, item.exercise?.currentStageId] + .filter(Boolean) + .some(value => String(value).toLowerCase().includes(query)); +} + +function workspaceMatchesFilters(item) { + const query = (element('adminWorkspaceSearch')?.value || '').trim().toLowerCase(); + const ownership = element('adminWorkspaceOwnershipFilter')?.value || 'all'; + if (ownership === 'independent' && item.classOwned) return false; + if (ownership === 'class' && !item.classOwned) return false; + if (ownership === 'expired' && item.status !== 'expired') return false; + if (!query) return true; + return [ + item.teamName, + item.id, + item.classroom?.title, + item.classroom?.workspaceLabel + ].filter(Boolean).some(value => String(value).toLowerCase().includes(query)); +} + +function renderClassCard(item) { + const card = documentRef.createElement('article'); + card.className = 'admin-maintenance__item'; + card.dataset.adminClassId = item.id; + + const heading = documentRef.createElement('div'); + heading.className = 'admin-maintenance__item-heading'; + const identity = documentRef.createElement('div'); + const title = documentRef.createElement('strong'); + title.textContent = item.title; + const id = documentRef.createElement('small'); + id.textContent = item.id; + identity.append(title, id); + heading.append(identity, statusBadge(item.status)); + + const meta = documentRef.createElement('div'); + meta.className = 'admin-maintenance__meta'; + meta.append( + metaRow('Last activity', `${formatTimestamp(item.lastActivityAt)} · ${idleLabel(item)}`), + metaRow('Expires', formatTimestamp(item.expiresAt)), + metaRow('Participants', `${item.participantCount} · ${item.recentPresenceCount} recent presence`), + metaRow('Workspaces', String(item.workspaceCount)), + metaRow('Staged exercise', item.exercise + ? `${item.exercise.status}${item.exercise.currentStageId ? ` · ${item.exercise.currentStageId}` : ''}` + : 'None') + ); + + const cleanup = documentRef.createElement('div'); + cleanup.className = 'admin-maintenance__cleanup-counts'; + cleanup.textContent = `Cleanup footprint: ${item.workspaceCount} workspace(s), ${item.presenceCount} presence row(s), ${item.coachingCount} coaching row(s), ${item.exerciseCount} exercise(s), ${item.checkpointCount} checkpoint(s), ${item.releaseCount} release row(s).`; + + const actions = documentRef.createElement('div'); + actions.className = 'admin-maintenance__actions'; + if (item.status === 'active') { + actions.append( + actionButton('Reissue Instructor access', () => rotateInstructor(item)), + actionButton('Close class', () => revokeClass(item), { danger: true }) + ); + } else { + actions.append( + actionButton('Preview purge', () => requestPurgePreview('classes', 'single', item.id), { danger: true }) + ); + } + + card.append(heading, meta, cleanup, actions); + return card; +} + +function renderWorkspaceCard(item) { + const card = documentRef.createElement('article'); + card.className = 'admin-maintenance__item'; + card.dataset.adminWorkspaceId = item.id; + + const heading = documentRef.createElement('div'); + heading.className = 'admin-maintenance__item-heading'; + const identity = documentRef.createElement('div'); + const title = documentRef.createElement('strong'); + title.textContent = item.teamName; + const id = documentRef.createElement('small'); + id.textContent = item.id; + identity.append(title, id); + const badges = documentRef.createElement('div'); + badges.className = 'admin-maintenance__badges'; + badges.append( + statusBadge(item.status), + statusBadge(item.classOwned ? 'class-owned' : 'independent') + ); + heading.append(identity, badges); + + const meta = documentRef.createElement('div'); + meta.className = 'admin-maintenance__meta'; + meta.append( + metaRow('Last activity', `${formatTimestamp(item.lastActivityAt)} · ${idleLabel(item)}`), + metaRow('Expires', formatTimestamp(item.expiresAt)), + metaRow('Participants', `${item.participantCount} · ${item.recentPresenceCount} recent presence`), + metaRow('Capabilities', String(item.capabilityCount)), + metaRow('Owner', item.classOwned + ? `${item.classroom?.title || 'Classroom'} · ${item.classroom?.workspaceLabel || 'workspace'}` + : 'Standalone / ad-hoc') + ); + + const actions = documentRef.createElement('div'); + actions.className = 'admin-maintenance__actions'; + const threshold = currentIdleDays(); + const independentlyPurgeable = !item.classOwned + && (item.status === 'expired' || (Number.isFinite(item.idleDays) && item.idleDays >= threshold)); + if (independentlyPurgeable) { + actions.append( + actionButton('Preview purge', () => requestPurgePreview('workspaces', 'single', item.id), { danger: true }) + ); + } else if (item.classOwned) { + const note = documentRef.createElement('small'); + note.textContent = 'Class-owned workspaces are purged with their class.'; + actions.append(note); + } + + card.append(heading, meta, actions); + return card; +} + +function renderEmpty(container, message) { + const empty = documentRef.createElement('p'); + empty.className = 'admin-maintenance__empty'; + empty.textContent = message; + container.append(empty); +} + +function renderInventory() { + const classList = element('adminClassList'); + const workspaceList = element('adminWorkspaceList'); + if (!classList || !workspaceList) return; + classList.replaceChildren(); + workspaceList.replaceChildren(); + + const classes = (inventory.classes || []).filter(classMatchesFilters); + const workspaces = (inventory.workspaces || []).filter(workspaceMatchesFilters); + + classes.forEach(item => classList.append(renderClassCard(item))); + workspaces.forEach(item => workspaceList.append(renderWorkspaceCard(item))); + + if (!classes.length) renderEmpty(classList, 'No classes match these filters.'); + if (!workspaces.length) renderEmpty(workspaceList, 'No collaboration workspaces match these filters.'); + + const classSummary = element('adminClassSummary'); + const workspaceSummary = element('adminWorkspaceSummary'); + if (classSummary) classSummary.textContent = `${classes.length} of ${inventory.classes?.length || 0}`; + if (workspaceSummary) workspaceSummary.textContent = `${workspaces.length} of ${inventory.workspaces?.length || 0}`; + + const generated = element('adminInventoryGeneratedAt'); + if (generated) generated.textContent = inventory.generatedAt + ? `Inventory refreshed ${formatTimestamp(inventory.generatedAt)}` + : ''; +} + +async function loadInventory({ announce = false } = {}) { + if (!validToken(adminToken)) { + showAuth(); + return false; + } + setBusy(true); + setStatus('Refreshing maintenance inventory…', 'loading'); + try { + const result = await requestAdmin('GET'); + if (!result.ok) { + if (result.status === 401) { + clearSessionToken(); + showAuth(); + } + setStatus(result.body?.error || 'Unable to load maintenance inventory.', 'error'); + return false; + } + inventory = result.body || { classes: [], workspaces: [] }; + showConsole(); + renderInventory(); + setStatus('', ''); + if (announce) toastRef?.('Maintenance inventory refreshed.'); + return true; + } catch { + setStatus('Unable to reach Administration / Maintenance.', 'error'); + return false; + } finally { + setBusy(false); + } +} + +async function authenticate() { + const input = element('adminMaintenanceToken'); + const candidate = input?.value?.trim() || ''; + if (!validToken(candidate)) { + setStatus('Enter a valid Admin access key.', 'error'); + input?.focus(); + return; + } + setBusy(true); + setStatus('Checking Admin access…', 'loading'); + try { + const result = await requestAdmin('GET', null, candidate); + if (!result.ok) { + setStatus(result.body?.error || 'Admin access was not accepted.', 'error'); + return; + } + adminToken = candidate; + persistSessionToken(candidate); + inventory = result.body || { classes: [], workspaces: [] }; + if (input) input.value = ''; + showConsole(); + renderInventory(); + setStatus('', ''); + } catch { + setStatus('Unable to reach Administration / Maintenance.', 'error'); + } finally { + setBusy(false); + } +} + +function signOut() { + clearSessionToken(); + inventory = { classes: [], workspaces: [] }; + activePreview = null; + hidePreview(); + hideRecovery(); + setStatus('Admin session cleared for this tab.', 'info'); + showAuth(); +} + +async function revokeClass(item) { + if (!windowRef?.confirm?.(`Close “${item.title}”? Students will no longer be able to join or edit class workspaces.`)) return; + setBusy(true); + try { + const result = await requestAdmin('POST', { action: 'revoke-class', classId: item.id }); + if (!result.ok) { + setStatus(result.body?.error || 'Unable to close class.', 'error'); + return; + } + toastRef?.('Class closed.'); + await loadInventory(); + } catch { + setStatus('Unable to close class.', 'error'); + } finally { + setBusy(false); + } +} + +async function rotateInstructor(item) { + if (!windowRef?.confirm?.(`Reissue Instructor access for “${item.title}”? The previous Instructor credential will stop working immediately.`)) return; + setBusy(true); + try { + const result = await requestAdmin('POST', { action: 'rotate-instructor', classId: item.id }); + if (!result.ok) { + setStatus(result.body?.error || 'Unable to reissue Instructor access.', 'error'); + return; + } + showRecovery(item, result.body?.instructorToken || ''); + await loadInventory(); + } catch { + setStatus('Unable to reissue Instructor access.', 'error'); + } finally { + setBusy(false); + } +} + +function showRecovery(item, token) { + if (!validToken(token)) { + setStatus('Instructor access was rotated, but the returned credential was invalid.', 'error'); + return; + } + const panel = element('adminRecoveryPanel'); + const title = element('adminRecoveryTitle'); + const value = element('adminRecoveryToken'); + if (title) title.textContent = `New Instructor access · ${item.title}`; + if (value) value.value = token; + if (panel) { + panel.hidden = false; + panel.querySelector('textarea, button')?.focus?.(); + } +} + +function hideRecovery() { + const panel = element('adminRecoveryPanel'); + const value = element('adminRecoveryToken'); + if (value) value.value = ''; + if (panel) panel.hidden = true; +} + +async function copyRecoveryToken() { + const value = element('adminRecoveryToken')?.value || ''; + if (!validToken(value)) return; + try { + await windowRef?.navigator?.clipboard?.writeText?.(value); + toastRef?.('Instructor access copied.'); + } catch { + const field = element('adminRecoveryToken'); + field?.focus?.(); + field?.select?.(); + toastRef?.('Copy the selected Instructor access value.'); + } +} + +async function requestPurgePreview(scope, mode, id = null) { + setBusy(true); + try { + const body = { + action: 'preview-purge', + scope, + mode, + ...(scope === 'workspaces' || mode === 'bulk' ? { idleDays: currentIdleDays() } : {}), + ...(id ? { id } : {}) + }; + const result = await requestAdmin('POST', body); + if (!result.ok) { + setStatus(result.body?.error || 'Unable to build purge preview.', 'error'); + return; + } + if (!result.body?.previewToken || !result.body?.plan?.items?.length) { + toastRef?.('No records are eligible for this purge.'); + return; + } + activePreview = { + token: result.body.previewToken, + expiresAt: result.body.expiresAt, + plan: result.body.plan + }; + renderPreview(); + } catch { + setStatus('Unable to build purge preview.', 'error'); + } finally { + setBusy(false); + } +} + +function renderPreview() { + const panel = element('adminPurgePreview'); + const title = element('adminPurgePreviewTitle'); + const meta = element('adminPurgePreviewMeta'); + const list = element('adminPurgePreviewList'); + const confirm = element('adminPurgeConfirmBtn'); + if (!panel || !activePreview || !list) return; + + const { plan } = activePreview; + const label = plan.scope === 'classes' ? 'class' : 'workspace'; + if (title) title.textContent = `Confirm ${label} purge`; + if (meta) { + meta.textContent = `${plan.items.length} ${label}${plan.items.length === 1 ? '' : 'es'} · preview expires ${formatTimestamp(activePreview.expiresAt)}${plan.truncated ? ' · limited to first 200 candidates' : ''}`; + } + + list.replaceChildren(); + plan.items.forEach(item => { + const li = documentRef.createElement('li'); + const strong = documentRef.createElement('strong'); + strong.textContent = plan.scope === 'classes' ? item.title : item.teamName; + const detail = documentRef.createElement('span'); + detail.textContent = plan.scope === 'classes' + ? `${item.status} · ${item.participantCount} participant(s) · ${item.workspaceCount} workspace(s) · last activity ${formatTimestamp(item.lastActivityAt)}` + : `${item.status} · ${item.participantCount} participant(s) · ${item.capabilityCount} capability row(s) · last activity ${formatTimestamp(item.lastActivityAt)}`; + li.append(strong, detail); + list.append(li); + }); + if (confirm) confirm.textContent = `Purge ${plan.items.length} ${label}${plan.items.length === 1 ? '' : 's'}`; + panel.hidden = false; + confirm?.focus?.(); +} + +function hidePreview() { + activePreview = null; + const panel = element('adminPurgePreview'); + if (panel) panel.hidden = true; + element('adminPurgePreviewList')?.replaceChildren(); +} + +async function commitPreview() { + if (!activePreview?.token) return; + const label = activePreview.plan.scope === 'classes' ? 'class data' : 'workspace data'; + if (!windowRef?.confirm?.(`Permanently purge the previewed ${label}? This cannot be undone.`)) return; + + setBusy(true); + try { + const result = await requestAdmin('POST', { + action: 'commit-purge', + previewToken: activePreview.token + }); + if (!result.ok) { + if (result.status === 409) { + hidePreview(); + await loadInventory(); + } + setStatus(result.body?.error || 'Unable to purge maintenance data.', 'error'); + return; + } + const count = Array.isArray(result.body?.ids) ? result.body.ids.length : 0; + hidePreview(); + toastRef?.(`Purged ${count} maintenance record${count === 1 ? '' : 's'}.`); + await loadInventory(); + } catch { + setStatus('Unable to purge maintenance data.', 'error'); + } finally { + setBusy(false); + } +} + +function adminFocusables() { + const gate = element('adminMaintenanceGate'); + if (!gate || gate.hidden) return []; + return [...gate.querySelectorAll('button, input, select, textarea, [href], [tabindex]:not([tabindex="-1"])')] + .filter(control => !control.hidden && !control.disabled && !control.closest('[hidden]')); +} + +function handleKeydown(event) { + const gate = element('adminMaintenanceGate'); + if (!gate || gate.hidden) return; + if (event.key === 'Escape') { + event.preventDefault(); + if (!element('adminPurgePreview')?.hidden) { + hidePreview(); + return; + } + if (!element('adminRecoveryPanel')?.hidden) { + hideRecovery(); + return; + } + closeAdminMaintenance(); + return; + } + if (event.key !== 'Tab') return; + const focusables = adminFocusables(); + if (!focusables.length) return; + const current = focusables.indexOf(documentRef.activeElement); + const last = focusables.length - 1; + if (event.shiftKey && current <= 0) { + event.preventDefault(); + focusables[last].focus(); + } else if (!event.shiftKey && current === last) { + event.preventDefault(); + focusables[0].focus(); + } +} + +/** Open the privileged Administration / Maintenance surface. */ +export function openAdminMaintenance({ returnFocus: nextReturnFocus = null } = {}) { + const gate = element('adminMaintenanceGate'); + if (!gate) return; + returnFocus = nextReturnFocus || documentRef?.activeElement || null; + gate.hidden = false; + gate.setAttribute('aria-hidden', 'false'); + documentRef.body.classList.add('admin-maintenance-open'); + setStatus('', ''); + if (validToken(adminToken)) { + void loadInventory(); + } else { + showAuth(); + } +} + +/** Close the Admin surface without destroying the tab-scoped authenticated session. */ +export function closeAdminMaintenance() { + const gate = element('adminMaintenanceGate'); + if (!gate) return; + hidePreview(); + hideRecovery(); + gate.hidden = true; + gate.setAttribute('aria-hidden', 'true'); + documentRef.body.classList.remove('admin-maintenance-open'); + const target = returnFocus; + returnFocus = null; + target?.focus?.(); +} + +function bindControls() { + documentRef.querySelectorAll('[data-open-admin-maintenance]').forEach(button => { + if (button.dataset.adminMaintenanceBound === 'true') return; + button.dataset.adminMaintenanceBound = 'true'; + button.addEventListener('click', () => openAdminMaintenance({ returnFocus: button })); + }); + + element('adminMaintenanceCloseBtn')?.addEventListener('click', closeAdminMaintenance); + element('adminMaintenanceAuthForm')?.addEventListener('submit', event => { + event.preventDefault(); + void authenticate(); + }); + element('adminMaintenanceRefreshBtn')?.addEventListener('click', () => void loadInventory({ announce: true })); + element('adminMaintenanceSignOutBtn')?.addEventListener('click', signOut); + element('adminPurgeCancelBtn')?.addEventListener('click', hidePreview); + element('adminPurgeConfirmBtn')?.addEventListener('click', () => void commitPreview()); + element('adminRecoveryDismissBtn')?.addEventListener('click', hideRecovery); + element('adminRecoveryCopyBtn')?.addEventListener('click', () => void copyRecoveryToken()); + element('adminBulkClassPurgeBtn')?.addEventListener('click', () => void requestPurgePreview('classes', 'bulk')); + element('adminBulkWorkspacePurgeBtn')?.addEventListener('click', () => void requestPurgePreview('workspaces', 'bulk')); + + ['adminClassSearch', 'adminClassStatusFilter', 'adminWorkspaceSearch', 'adminWorkspaceOwnershipFilter', 'adminMaintenanceIdleDays'] + .forEach(id => { + const control = element(id); + control?.addEventListener(id.includes('Search') ? 'input' : 'change', renderInventory); + }); + + documentRef.addEventListener('keydown', handleKeydown); +} + +/** + * Initialize the Admin surface. + * + * @param {object} [options] Browser/test dependencies. + * @returns {object} Minimal controller for tests and integration. + */ +export function initAdminMaintenance({ + documentRef: nextDocument = typeof document !== 'undefined' ? document : null, + windowRef: nextWindow = typeof window !== 'undefined' ? window : null, + sessionStorage: nextSessionStorage = safeSessionStorage(), + fetchImpl = typeof fetch !== 'undefined' ? fetch.bind(globalThis) : null, + toast = nextWindow?.showToast || null +} = {}) { + documentRef = nextDocument; + windowRef = nextWindow; + sessionStorageRef = nextSessionStorage; + fetchRef = fetchImpl; + toastRef = toast; + adminToken = readSessionToken(); + inventory = { classes: [], workspaces: [] }; + activePreview = null; + bindControls(); + + return { + open: openAdminMaintenance, + close: closeAdminMaintenance, + refresh: () => loadInventory(), + signOut, + isAuthenticated: () => validToken(adminToken) + }; +} From 4f245e5e5bf5dcb62a6235229d4782bd66466f81 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:15:47 -0400 Subject: [PATCH 07/29] Add Administration Maintenance interface --- index.html | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/index.html b/index.html index 8c89e67..fc466b4 100644 --- a/index.html +++ b/index.html @@ -134,6 +134,10 @@

KT Intake

Experience Choose experience + +
+ + Inspect and clean server-side class or collaboration data with a separate Admin access key. +
+ + + +
From 1245807bba2fea7d56fba6208ca7702b4d074088 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:16:32 -0400 Subject: [PATCH 08/29] Align Admin auth form binding --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index fc466b4..d3178c4 100644 --- a/index.html +++ b/index.html @@ -310,7 +310,7 @@

Administration / Maintenance

-
+
Admin access required

The Admin key is separate from Student and Instructor access. After successful verification it is retained only for this browser tab and is cleared when you sign out or close the tab.

From 6b8ff33ad1105d02a7ab4157a9c669e28758c46b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:16:36 -0400 Subject: [PATCH 09/29] Initialize Administration Maintenance surface --- main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.js b/main.js index 1c78483..5ee8307 100644 --- a/main.js +++ b/main.js @@ -67,6 +67,7 @@ import { initClassroomDebriefComparison } from './src/classroomDebriefComparison import { initClassroomCaseStudies } from './src/classroomCaseStudies.js'; import { initInstructorExerciseConsole } from './src/classroomExerciseInstructor.js'; import { initStudentExerciseReference } from './src/classroomExerciseStudent.js'; +import { initAdminMaintenance } from './src/adminMaintenance.js'; /** Active shared-session controller, initialized during boot. @type {object|null} */ let collaborationController = null; @@ -195,6 +196,7 @@ function boot() { initThemeFromStorage(); initExperienceRoleController(); + initAdminMaintenance({ documentRef: document, windowRef: window, toast: showToast }); configureKT({ autoResize, From ff36aea590b21055cb0ced50f74d802c06026fd6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:16:42 -0400 Subject: [PATCH 10/29] Style Administration Maintenance console --- styles.css | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/styles.css b/styles.css index 3ffb219..f9b4d6a 100644 --- a/styles.css +++ b/styles.css @@ -338,6 +338,86 @@ body.experience-role-gate-open{overflow:hidden;} border-top:1px solid var(--line); } .experience-role-dialog__footer p{margin:0;color:var(--muted);font-size:12px;line-height:1.5;} +.experience-role-admin-entry{display:flex;align-items:center;gap:12px;margin-top:18px;padding:14px 16px;border:1px solid var(--line);border-radius:14px;background:var(--panel-muted);} +.experience-role-admin-entry span{color:var(--muted);font-size:11px;line-height:1.45;} +body.admin-maintenance-open{overflow:hidden;} +.admin-maintenance-gate{position:fixed;inset:0;z-index:520;display:grid;place-items:center;padding:20px;background:color-mix(in srgb,var(--ink-strong) 42%,transparent);backdrop-filter:blur(18px);} +.admin-maintenance-gate[hidden]{display:none;} +.admin-maintenance{box-sizing:border-box;width:min(1180px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:24px;border:1px solid var(--line);border-radius:22px;background:var(--panel-raised);box-shadow:var(--shadow-strong);} +.admin-maintenance__header{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:18px;border-bottom:1px solid var(--line);} +.admin-maintenance__header h2{margin:0;color:var(--ink-strong);font-size:28px;letter-spacing:-.025em;} +.admin-maintenance__header p{max-width:800px;margin:8px 0 0;color:var(--muted);font-size:13px;line-height:1.55;} +.admin-maintenance__status{margin:14px 0 0;padding:10px 12px;border:1px solid var(--line);border-radius:11px;background:var(--panel-muted);color:var(--ink);font-size:12px;} +.admin-maintenance__status[data-state="error"]{border-color:var(--mi-role-red-border);background:var(--mi-role-red-bg);color:var(--mi-role-red);} +.admin-maintenance__status[data-state="loading"]{border-color:var(--mi-role-blue-border);background:var(--mi-role-blue-bg);color:var(--mi-role-blue);} +.admin-maintenance__auth{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(240px,.8fr) auto;align-items:end;gap:16px;margin-top:18px;padding:18px;border:1px solid var(--line);border-radius:16px;background:var(--panel);} +.admin-maintenance__auth-copy strong{display:block;color:var(--ink-strong);font-size:14px;} +.admin-maintenance__auth-copy p{margin:5px 0 0;color:var(--muted);font-size:11px;line-height:1.5;} +.admin-maintenance__console{margin-top:18px;} +.admin-maintenance__toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;border:1px solid var(--line);border-radius:14px;background:var(--panel-muted);} +.admin-maintenance__toolbar-copy{display:flex;flex-direction:column;gap:2px;} +.admin-maintenance__toolbar-copy strong{color:var(--ink-strong);font-size:13px;} +.admin-maintenance__toolbar-copy span{color:var(--muted);font-size:10px;} +.admin-maintenance__toolbar-actions{display:flex;align-items:end;justify-content:flex-end;gap:8px;flex-wrap:wrap;} +.admin-maintenance__threshold{display:flex;flex-direction:column;gap:4px;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;} +.admin-maintenance__threshold select{min-width:118px;text-transform:none;letter-spacing:normal;font-size:11px;font-weight:600;} +.admin-maintenance__section{margin-top:18px;padding:18px;border:1px solid var(--line);border-radius:16px;background:var(--panel);} +.admin-maintenance__section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;} +.admin-maintenance__section-heading h3{margin:0;color:var(--ink-strong);font-size:18px;} +.admin-maintenance__section-heading p{margin:5px 0 0;color:var(--muted);font-size:11px;line-height:1.45;} +.admin-maintenance__count{flex:0 0 auto;padding:4px 8px;border:1px solid var(--line);border-radius:999px;background:var(--panel-muted);color:var(--muted);font-size:10px;font-weight:800;} +.admin-maintenance__filters{display:grid;grid-template-columns:minmax(180px,1fr) minmax(150px,.45fr) auto;align-items:end;gap:10px;margin-top:14px;padding-top:14px;border-top:1px solid var(--line);} +.admin-maintenance__bulk{white-space:nowrap;} +.admin-maintenance__list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px;} +.admin-maintenance__item{min-width:0;padding:13px;border:1px solid var(--line);border-radius:13px;background:var(--panel-muted);} +.admin-maintenance__item-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;} +.admin-maintenance__item-heading>div:first-child{min-width:0;} +.admin-maintenance__item-heading strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink-strong);font-size:12px;} +.admin-maintenance__item-heading small{display:block;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font:9px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;} +.admin-maintenance__badges{display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end;} +.admin-maintenance__badge{display:inline-flex;align-items:center;padding:3px 6px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--muted);font-size:8px;font-weight:850;text-transform:uppercase;letter-spacing:.04em;} +.admin-maintenance__badge--active,.admin-maintenance__badge--independent{border-color:var(--mi-role-green-border);background:var(--mi-role-green-bg);color:var(--mi-role-green);} +.admin-maintenance__badge--expired{border-color:var(--mi-role-orange-border);background:var(--mi-role-orange-bg);color:var(--mi-role-orange);} +.admin-maintenance__badge--revoked{border-color:var(--mi-role-red-border);background:var(--mi-role-red-bg);color:var(--mi-role-red);} +.admin-maintenance__badge--class-owned{border-color:var(--mi-role-blue-border);background:var(--mi-role-blue-bg);color:var(--mi-role-blue);} +.admin-maintenance__meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 10px;margin-top:10px;padding-top:10px;border-top:1px solid var(--line);} +.admin-maintenance__meta-row{min-width:0;display:flex;flex-direction:column;gap:1px;} +.admin-maintenance__meta-row span{color:var(--muted);font-size:8px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;} +.admin-maintenance__meta-row strong{overflow:hidden;text-overflow:ellipsis;color:var(--ink);font-size:10px;line-height:1.35;} +.admin-maintenance__cleanup-counts{margin-top:9px;padding:7px 8px;border-radius:9px;background:var(--panel);color:var(--muted);font-size:9px;line-height:1.4;} +.admin-maintenance__actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-top:10px;} +.admin-maintenance__actions small{color:var(--muted);font-size:9px;line-height:1.4;} +.admin-maintenance__actions .btn,.admin-maintenance__actions .btn-secondary{padding:6px 8px;font-size:9px;} +.admin-maintenance__danger{border-color:var(--mi-role-red-border);color:var(--mi-role-red);} +.admin-maintenance__danger-primary{border-color:var(--mi-role-red);background:var(--mi-role-red);color:#fff;} +.admin-maintenance__empty{grid-column:1/-1;margin:0;padding:18px;border:1px dashed var(--line);border-radius:11px;color:var(--muted);font-size:11px;text-align:center;} +.admin-maintenance__confirm,.admin-maintenance__recovery{position:sticky;bottom:0;z-index:2;margin-top:16px;padding:16px;border:1px solid var(--mi-role-orange-border);border-radius:15px;background:var(--panel-raised);box-shadow:var(--shadow-strong);} +.admin-maintenance__confirm h3,.admin-maintenance__recovery h3{margin:0;color:var(--ink-strong);font-size:16px;} +.admin-maintenance__confirm p,.admin-maintenance__recovery p{margin:5px 0 0;color:var(--muted);font-size:10px;line-height:1.45;} +.admin-maintenance__confirm ul{display:flex;flex-direction:column;gap:5px;max-height:180px;overflow:auto;margin:10px 0 0;padding:0;list-style:none;} +.admin-maintenance__confirm li{padding:8px 9px;border:1px solid var(--line);border-radius:9px;background:var(--panel-muted);} +.admin-maintenance__confirm li strong,.admin-maintenance__confirm li span{display:block;} +.admin-maintenance__confirm li strong{color:var(--ink-strong);font-size:10px;} +.admin-maintenance__confirm li span{margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;} +.admin-maintenance__warning{font-weight:700;color:var(--mi-role-orange)!important;} +.admin-maintenance__recovery{border-color:var(--mi-role-blue-border);} +.admin-maintenance__recovery label{display:block;margin-top:10px;color:var(--ink-strong);font-size:10px;font-weight:800;} +.admin-maintenance__recovery textarea{box-sizing:border-box;width:100%;margin-top:5px;font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;resize:none;} +@media(max-width:760px){ + .experience-role-admin-entry{align-items:flex-start;flex-direction:column;} + .admin-maintenance-gate{padding:0;place-items:stretch;} + .admin-maintenance{width:100%;max-height:100vh;min-height:100vh;border-radius:0;padding:16px;} + .admin-maintenance__header{gap:10px;} + .admin-maintenance__header h2{font-size:23px;} + .admin-maintenance__auth{grid-template-columns:1fr;} + .admin-maintenance__toolbar{align-items:flex-start;flex-direction:column;} + .admin-maintenance__toolbar-actions{width:100%;justify-content:flex-start;} + .admin-maintenance__filters{grid-template-columns:1fr;} + .admin-maintenance__list{grid-template-columns:1fr;} + .admin-maintenance__meta{grid-template-columns:1fr;} + .admin-maintenance__section{padding:13px;} + .admin-maintenance__confirm,.admin-maintenance__recovery{bottom:8px;} +} .experience-shell{ max-width:1100px; margin:34px auto 48px; From 526b87f7d8a00b791a20fb4b4d1bfe7ba9427f04 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:17:34 -0400 Subject: [PATCH 11/29] Cover Admin maintenance browser lifecycle --- tests/e2e/admin-maintenance.spec.mjs | 357 +++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 tests/e2e/admin-maintenance.spec.mjs diff --git a/tests/e2e/admin-maintenance.spec.mjs b/tests/e2e/admin-maintenance.spec.mjs new file mode 100644 index 0000000..49e3356 --- /dev/null +++ b/tests/e2e/admin-maintenance.spec.mjs @@ -0,0 +1,357 @@ +/** + * Real-browser coverage for Administration / Maintenance. + */ + +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +const ADMIN = 'A'.repeat(43); +const INSTRUCTOR = 'R'.repeat(43); +const CLASS_ACTIVE = '11111111-1111-4111-8111-111111111111'; +const CLASS_EXPIRED = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_STALE = '33333333-3333-4333-8333-333333333333'; +const WORKSPACE_CLASS = '44444444-4444-4444-8444-444444444444'; +const ADMIN_SESSION_STORAGE_KEY = 'kt-admin-session-v1'; + +function watchPageErrors(page) { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + return errors; +} + +async function startFresh(page) { + await page.goto('/'); + await page.evaluate(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + await page.reload(); +} + +async function expectNoBlockingA11yViolations(page) { + const accessibility = await new AxeBuilder({ page }).analyze(); + const blockingViolations = accessibility.violations.filter( + violation => violation.impact === 'serious' || violation.impact === 'critical' + ); + expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]); +} + +function fixtureInventory() { + return { + generatedAt: '2026-10-08T18:00:00.000Z', + classes: [ + { + id: CLASS_ACTIVE, + title: 'Active Browser Class', + status: 'active', + joinsEnabled: true, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2099-12-31T23:59:59.000Z', + revokedAt: null, + lastActivityAt: '2026-10-08T17:59:00.000Z', + idleDays: 0, + participantCount: 2, + workspaceCount: 1, + presenceCount: 2, + recentPresenceCount: 1, + coachingCount: 1, + exerciseCount: 1, + checkpointCount: 1, + releaseCount: 2, + workspaces: [{ id: WORKSPACE_CLASS, label: 'Team A', kind: 'group' }], + exercise: { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + updatedAt: '2026-10-08T17:58:00.000Z' + } + }, + { + id: CLASS_EXPIRED, + title: 'Expired Browser Class', + status: 'expired', + joinsEnabled: false, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-15T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + revokedAt: null, + lastActivityAt: '2026-07-20T00:00:00.000Z', + idleDays: 80, + participantCount: 1, + workspaceCount: 0, + presenceCount: 0, + recentPresenceCount: 0, + coachingCount: 0, + exerciseCount: 0, + checkpointCount: 0, + releaseCount: 0, + workspaces: [], + exercise: null + } + ], + workspaces: [ + { + id: WORKSPACE_STALE, + teamName: 'Old Browser Collaboration', + status: 'active', + createdAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z', + expiresAt: '2099-12-31T23:59:59.000Z', + lastActivityAt: '2026-06-02T00:00:00.000Z', + idleDays: 128, + participantCount: 2, + recentPresenceCount: 0, + capabilityCount: 0, + classOwned: false, + classroom: null + }, + { + id: WORKSPACE_CLASS, + teamName: 'Team A', + status: 'active', + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + expiresAt: '2099-12-31T23:59:59.000Z', + lastActivityAt: '2026-10-08T17:59:00.000Z', + idleDays: 0, + participantCount: 2, + recentPresenceCount: 1, + capabilityCount: 2, + classOwned: true, + classroom: { + id: CLASS_ACTIVE, + title: 'Active Browser Class', + workspaceId: WORKSPACE_CLASS, + workspaceKind: 'group', + workspaceLabel: 'Team A' + } + } + ] + }; +} + +async function installAdminFixture(page) { + let inventory = fixtureInventory(); + const requests = []; + + await page.route('**/api/admin', async route => { + const request = route.request(); + const headers = request.headers(); + const authorization = headers.authorization || ''; + let body = {}; + try { + body = request.postDataJSON() || {}; + } catch { + body = {}; + } + requests.push({ method: request.method(), authorization, body }); + + const fulfill = (status, payload) => route.fulfill({ + status, + contentType: 'application/json', + headers: { + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer' + }, + body: JSON.stringify(payload) + }); + + if (authorization !== `Bearer ${ADMIN}`) { + return fulfill(401, { error: 'Administration authorization required.' }); + } + + if (request.method() === 'GET') { + return fulfill(200, inventory); + } + + if (body.action === 'rotate-instructor' && body.classId === CLASS_ACTIVE) { + return fulfill(200, { + classroom: { + id: CLASS_ACTIVE, + title: 'Active Browser Class', + joinsEnabled: true, + expiresAt: '2099-12-31T23:59:59.000Z' + }, + instructorToken: INSTRUCTOR + }); + } + + if (body.action === 'revoke-class' && body.classId === CLASS_ACTIVE) { + inventory = { + ...inventory, + classes: inventory.classes.map(item => item.id === CLASS_ACTIVE + ? { ...item, status: 'revoked', joinsEnabled: false, revokedAt: '2026-10-08T18:00:00.000Z' } + : item) + }; + return fulfill(200, { revoked: true, classId: CLASS_ACTIVE }); + } + + if (body.action === 'preview-purge' && body.scope === 'classes') { + const expired = inventory.classes.filter(item => item.id === CLASS_EXPIRED); + return fulfill(200, expired.length + ? { + plan: { + scope: 'classes', + mode: body.mode, + idleDays: body.idleDays ?? null, + cutoffAt: body.idleDays ? '2026-09-08T18:00:00.000Z' : null, + generatedAt: '2026-10-08T18:00:00.000Z', + truncated: false, + items: expired + }, + previewToken: 'preview-classes', + expiresAt: '2026-10-08T18:10:00.000Z' + } + : { plan: { scope: 'classes', mode: body.mode, items: [] }, previewToken: null, expiresAt: null }); + } + + if (body.action === 'preview-purge' && body.scope === 'workspaces') { + const stale = inventory.workspaces.filter(item => item.id === WORKSPACE_STALE); + return fulfill(200, stale.length + ? { + plan: { + scope: 'workspaces', + mode: body.mode, + idleDays: body.idleDays, + cutoffAt: '2026-09-08T18:00:00.000Z', + generatedAt: '2026-10-08T18:00:00.000Z', + truncated: false, + items: stale + }, + previewToken: 'preview-workspaces', + expiresAt: '2026-10-08T18:10:00.000Z' + } + : { plan: { scope: 'workspaces', mode: body.mode, items: [] }, previewToken: null, expiresAt: null }); + } + + if (body.action === 'commit-purge' && body.previewToken === 'preview-classes') { + inventory = { + ...inventory, + classes: inventory.classes.filter(item => item.id !== CLASS_EXPIRED) + }; + return fulfill(200, { purged: true, scope: 'classes', ids: [CLASS_EXPIRED], workspaceCount: 0 }); + } + + if (body.action === 'commit-purge' && body.previewToken === 'preview-workspaces') { + inventory = { + ...inventory, + workspaces: inventory.workspaces.filter(item => item.id !== WORKSPACE_STALE) + }; + return fulfill(200, { purged: true, scope: 'workspaces', ids: [WORKSPACE_STALE] }); + } + + return fulfill(400, { error: 'Unexpected browser fixture request.' }); + }); + + return { requests, getInventory: () => inventory }; +} + +async function openAndAuthenticate(page) { + await page.getByRole('button', { name: 'Administration / Maintenance', exact: true }).click(); + await expect(page.locator('#adminMaintenanceGate')).toBeVisible(); + await page.getByLabel('Admin access key').fill(ADMIN); + await page.getByRole('button', { name: 'Open maintenance console' }).click(); + await expect(page.locator('#adminMaintenanceConsole')).toBeVisible(); +} + +test('Administration authenticates in tab scope without becoming an Intake experience role', async ({ page }) => { + const pageErrors = watchPageErrors(page); + const fixture = await installAdminFixture(page); + await startFresh(page); + + await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected'); + await openAndAuthenticate(page); + + await expect(page.getByText('Active Browser Class', { exact: true })).toBeVisible(); + await expect(page.getByText('Expired Browser Class', { exact: true })).toBeVisible(); + await expect(page.getByText('Old Browser Collaboration', { exact: true })).toBeVisible(); + await expect(page.getByText('Team A', { exact: true }).first()).toBeVisible(); + + const storage = await page.evaluate(key => ({ + session: window.sessionStorage.getItem(key), + localValues: Object.values(window.localStorage) + }), ADMIN_SESSION_STORAGE_KEY); + expect(JSON.parse(storage.session).token).toBe(ADMIN); + expect(storage.localValues.some(value => value.includes(ADMIN))).toBe(false); + await expect(page.locator('body')).toHaveAttribute('data-experience-role', 'unselected'); + + await page.getByLabel('Search classes').fill('Expired'); + await expect(page.getByText('Expired Browser Class', { exact: true })).toBeVisible(); + await expect(page.getByText('Active Browser Class', { exact: true })).toBeHidden(); + await page.getByLabel('Search classes').fill(''); + + await expectNoBlockingA11yViolations(page); + expect(fixture.requests[0]).toMatchObject({ + method: 'GET', + authorization: `Bearer ${ADMIN}` + }); + expect(pageErrors).toEqual([]); + + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(page.locator('#adminMaintenanceGate')).toBeHidden(); + await expect(page.locator('#experienceRoleGate')).toBeVisible(); +}); + +test('Administration previews exact stale records before purge and protects class-owned sessions', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-mobile', 'Destructive Admin workflow runs once on desktop; mobile accessibility is covered by the Admin entry test.'); + const pageErrors = watchPageErrors(page); + const fixture = await installAdminFixture(page); + await startFresh(page); + await openAndAuthenticate(page); + + await page.getByRole('button', { name: 'Preview stale / expired classes' }).click(); + const classPreview = page.locator('#adminPurgePreview'); + await expect(classPreview).toBeVisible(); + await expect(classPreview).toContainText('Expired Browser Class'); + await expect(classPreview).not.toContainText('Active Browser Class'); + + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Purge 1 class' }).click(); + await expect(page.getByText('Expired Browser Class', { exact: true })).toHaveCount(0); + await expect(page.getByText('Active Browser Class', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Preview stale independent workspaces' }).click(); + const workspacePreview = page.locator('#adminPurgePreview'); + await expect(workspacePreview).toBeVisible(); + await expect(workspacePreview).toContainText('Old Browser Collaboration'); + await expect(workspacePreview).not.toContainText('Team A'); + + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Purge 1 workspace' }).click(); + await expect(page.getByText('Old Browser Collaboration', { exact: true })).toHaveCount(0); + await expect(page.getByText('Team A', { exact: true }).first()).toBeVisible(); + + const commits = fixture.requests.filter(item => item.body.action === 'commit-purge'); + expect(commits.map(item => item.body.previewToken)).toEqual(['preview-classes', 'preview-workspaces']); + expect(pageErrors).toEqual([]); +}); + +test('Administration can reissue Instructor authority without persisting the returned credential', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-mobile', 'Credential recovery workflow runs once on desktop; mobile accessibility is covered separately.'); + const pageErrors = watchPageErrors(page); + await installAdminFixture(page); + await startFresh(page); + await openAndAuthenticate(page); + + page.once('dialog', dialog => dialog.accept()); + await page.getByRole('button', { name: 'Reissue Instructor access' }).click(); + + const recovery = page.locator('#adminRecoveryPanel'); + await expect(recovery).toBeVisible(); + await expect(page.locator('#adminRecoveryToken')).toHaveValue(INSTRUCTOR); + + const storage = await page.evaluate(({ adminKey, instructor }) => ({ + session: window.sessionStorage.getItem(adminKey), + localContainsInstructor: Object.values(window.localStorage).some(value => value.includes(instructor)), + sessionContainsInstructor: Object.entries(window.sessionStorage) + .filter(([key]) => key !== adminKey) + .some(([, value]) => value.includes(instructor)) + }), { adminKey: ADMIN_SESSION_STORAGE_KEY, instructor: INSTRUCTOR }); + + expect(storage.localContainsInstructor).toBe(false); + expect(storage.sessionContainsInstructor).toBe(false); + expect(JSON.parse(storage.session).token).toBe(ADMIN); + expect(pageErrors).toEqual([]); +}); From 5542974b00c974a8e86560aeb1a242c01b0bc520 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:18:13 -0400 Subject: [PATCH 12/29] Document Administration Maintenance lifecycle contract --- docs/admin-maintenance.md | 312 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/admin-maintenance.md diff --git a/docs/admin-maintenance.md b/docs/admin-maintenance.md new file mode 100644 index 0000000..48a55ce --- /dev/null +++ b/docs/admin-maintenance.md @@ -0,0 +1,312 @@ +# Administration / Maintenance lifecycle contract + +Issue: #329 +Implementation PR: #333 + +## Purpose + +Administration / Maintenance is a privileged server-data lifecycle utility for Intake maintainers. + +It is **not** a fourth Intake experience role and does not modify the Standalone / Student / Instructor authorization model. + +The surface exists to: + +- inspect Classroom and collaboration records without direct database access; +- understand which records are active, expired, revoked, or idle; +- close a class immediately; +- reissue lost Instructor authority without restoring an old credential; +- preview exactly what a cleanup will remove; +- purge expired/revoked/stale Classroom data; +- purge stale independent Standalone/ad-hoc collaboration workspaces. + +There is no autonomous scheduled deletion in #329. + +## Admin authorization + +The server reads one environment-only credential: + +```text +INTAKE_ADMIN_TOKEN +``` + +Requirements: + +- exactly one 256-bit URL-safe capability value (43 base64url characters); +- configured only in the deployment environment; +- never bundled in browser assets; +- never written to Intake state, files, summaries, templates, URLs, logs, analytics, or telemetry; +- browser retention is allowed only in `sessionStorage` after successful server verification, under `kt-admin-session-v1`; +- Sign out removes the tab-scoped envelope; +- closing the browser tab removes the browser session value. + +The browser sends the key only in the HTTPS `Authorization: Bearer ...` header. + +The server compares credential hashes using a constant-time comparison. Missing/malformed server configuration fails closed with HTTP 503. Missing/incorrect request authority receives HTTP 401. + +All Admin responses use: + +```text +Cache-Control: no-store +Referrer-Policy: no-referrer +``` + +## HTTP surface + +#329 adds one deployable function: + +```text +/api/admin +``` + +No additional Classroom function wrappers are created. + +### GET /api/admin + +Returns the complete maintenance inventory visible to the Admin credential. + +It never returns: + +- Instructor credentials/hashes; +- Student session credentials/hashes; +- collaboration primary capability/hash; +- assignment-specific workspace credentials/hashes; +- snapshots; +- protected Case Study payloads. + +### POST /api/admin + +Supported actions: + +- `revoke-class` +- `rotate-instructor` +- `preview-purge` +- `commit-purge` + +Unknown actions fail closed. + +## Non-secret maintenance identities + +`collaboration_workspaces` has a UUID `public_id` used only as a non-secret maintenance identity. + +This UUID is **not authorization**. Normal collaboration clients continue to authorize using their existing high-entropy capability model. + +Existing workspace rows are backfilled idempotently during workspace schema initialization. + +## Inventory + +### Classes + +Each class inventory record includes: + +- public class UUID and title; +- active / expired / revoked status; +- joins-enabled state; +- created, updated, expiry, revocation timestamps; +- derived last-activity timestamp and idle-day count; +- current participant/workspace counts; +- all stored presence-row count and recent-presence count; +- coaching, exercise, checkpoint, and release cleanup counts; +- public Classroom workspace IDs/labels/kinds; +- current non-completed staged exercise state when present. + +Class last activity is derived server-side from the latest meaningful timestamp across: + +- class updates; +- participant updates; +- Classroom workspace updates; +- underlying collaboration snapshot updates; +- participant presence / last-active timestamps; +- staged exercise updates; +- coaching updates. + +### Collaboration workspaces + +Each collaboration workspace inventory record includes: + +- non-secret maintenance UUID; +- team name; +- active / expired status; +- created / updated / expiry timestamps; +- derived last activity and idle-day count; +- participant/presence counts; +- capability-row count; +- whether the workspace is Classroom-owned or independent; +- owning public class/workspace metadata when Classroom-owned. + +Workspace last activity uses both snapshot update time and presence activity. + +**Recent presence therefore prevents an old snapshot from being treated as idle.** + +## Close / revoke class + +Admin can close an active class by public class UUID. + +Closing: + +- sets the class revoked timestamp; +- disables Student joins; +- marks Classroom workspace mappings revoked; +- expires underlying class-owned collaboration workspaces immediately. + +It does not physically delete rows. Physical deletion remains a separate preview-first purge. + +## Instructor recovery + +Admin can rotate/reissue Instructor authority for an active, non-expired, non-revoked class. + +The server: + +1. creates a fresh high-entropy Instructor capability; +2. stores only its SHA-256 hash; +3. invalidates the previous Instructor capability atomically with the row update; +4. returns the new raw capability once. + +The browser displays the returned value in a one-time recovery panel and does **not** persist it. + +The Admin credential can never recover a historical raw Instructor capability from its stored hash. + +## Purge eligibility + +### Classes + +A class is eligible when: + +- it is revoked; or +- it is expired; or +- a **bulk** purge threshold is selected and the derived last activity is older than the server-computed cutoff. + +A single-class purge is intentionally limited to expired/revoked classes. + +### Independent collaboration workspaces + +An independent workspace is eligible when: + +- it is expired; or +- its derived last activity is older than the selected cutoff. + +Class-owned collaboration workspaces are never independently purged. They are removed only as part of their owning class purge. + +## Preview-first destructive workflow + +Purge is two-step. + +### 1. Preview + +The server computes the current eligible candidate set and returns: + +- exact candidate objects visible to the maintainer; +- a short-lived signed preview token; +- preview expiry; +- cutoff/threshold metadata. + +The signed token contains only non-secret public identifiers and fingerprints. + +Preview lifetime: **10 minutes**. + +At most 200 candidates are included in one purge plan. A truncated preview is clearly marked and commits only the exact displayed candidates. + +### 2. Commit + +The browser sends only the signed preview token. + +Before deletion, the server: + +1. validates the preview signature and expiry; +2. re-reads current inventory; +3. rebuilds the plan using the original cutoff; +4. compares every candidate fingerprint; +5. rejects with HTTP 409 if state/activity changed; +6. runs guarded deletion using the exact public IDs from the signed plan. + +A new or recently active record can never be silently added to an old preview. + +## Cascade behavior + +### Class purge + +Class purge removes: + +- class-owned collaboration workspaces; +- collaboration capabilities and presence rows through collaboration FK cascade; +- Classroom workspace mappings; +- Classroom participants; +- coaching feedback; +- exercises; +- releases; +- readiness/workspace state; +- checkpoints; +- the class row. + +The deletion statement rechecks that every requested class is still terminal/eligible before mutation. + +### Independent workspace purge + +Independent purge deletes only collaboration workspaces with no Classroom ownership row. + +Capabilities and presence cascade with the workspace. + +## Browser UI + +Administration / Maintenance is available as a secondary privileged entry from: + +- the current experience chooser; +- View -> Administration / Maintenance. + +Opening Admin does **not** set or change `data-experience-role`. + +The console provides: + +- class and workspace search; +- lifecycle/ownership filters; +- configurable idle threshold; +- refresh; +- tab-session Sign out; +- class close; +- Instructor access reissue; +- single terminal-class preview; +- bulk stale/expired class preview; +- eligible independent-workspace preview; +- bulk stale independent-workspace preview; +- exact purge confirmation; +- responsive single-column mobile layout; +- keyboard focus containment and Escape handling. + +## Current environment dependency + +Code can merge safely before `INTAKE_ADMIN_TOKEN` is configured: the endpoint fails closed with HTTP 503 and the browser shows the server error. + +Production acceptance for #329 requires configuring a valid server environment key and verifying the exact merged deployment. + +## Tests + +Required coverage includes: + +- missing/malformed server configuration; +- missing/incorrect request authorization; +- private response headers; +- no raw capability leakage; +- active/recent-presence protection; +- class-owned workspace protection; +- exact signed preview -> commit; +- stale preview rejection; +- terminal-only single-class purge; +- preview expiry; +- Instructor authority rotation; +- function-budget regression; +- real browser Admin auth/session isolation; +- desktop destructive preview flow; +- Instructor recovery value is not persisted; +- mobile/desktop serious/critical axe check. + +## Cold restart + +For #329 work: + +1. refresh `main`, PR #333, and issue #329; +2. read root `AGENTS.md`, `api/AGENTS.md`, `docs/preproduction-hardening.md`, and this file; +3. keep Administration separate from experience roles; +4. never add token values to URL/query strings; +5. never bypass preview-first physical deletion; +6. keep Class-owned workspace deletion coupled to owning class purge; +7. preserve one deployable `api/admin.js` Admin function; +8. before handoff, update `docs/classroom-workstream.md` with exact branch/HEAD/gates. From fb6a7d6245ea81a8cbd5bd55f62e078d28e7c43b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:18:53 -0400 Subject: [PATCH 13/29] Point Admin work to maintenance contract --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7ee2ce8..3fda9ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ See `docs/REPOSITORY-OPERATIONS.md#delivery-resilience-for-ai-assisted-work` for ## Long-running Classroom Program -The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. Staged exercise/debrief work under #313 must also read `docs/classroom-staged-simulation.md`. Work on universal Intake target identity, dynamic coaching targets, staged `intakeTargetIds`, or #319 debrief comparison must also read `docs/intake-target-identity.md`; that contract forbids template IDs and DOM selectors from becoming semantic target identity. Work on #328–#330, legacy credential/save cleanup, Administration/Maintenance, retention/purge behavior, or startup/resume routing must also read `docs/preproduction-hardening.md`; until #328 lands, legacy-path documentation may describe current runtime behavior even though the approved target is to remove that compatibility before public production. +The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. Staged exercise/debrief work under #313 must also read `docs/classroom-staged-simulation.md`. Work on universal Intake target identity, dynamic coaching targets, staged `intakeTargetIds`, or #319 debrief comparison must also read `docs/intake-target-identity.md`; that contract forbids template IDs and DOM selectors from becoming semantic target identity. Work on #328–#330, legacy credential/save cleanup, Administration/Maintenance, retention/purge behavior, or startup/resume routing must also read `docs/preproduction-hardening.md`. Work on #329, `/api/admin`, `INTAKE_ADMIN_TOKEN`, maintenance inventory, Instructor recovery, or physical purge must additionally read `docs/admin-maintenance.md`; Admin is not an experience role and destructive cleanup remains preview-first. Classroom invariants: From 01036df93d751afceef9e6fe4cecde0f1eb9513c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:18:58 -0400 Subject: [PATCH 14/29] Document Admin maintenance security boundary --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index ae9447f..4e73ce8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,12 +13,16 @@ Never commit, log, paste into issues, or include in test fixtures: - database connection strings or credentials; - raw collaboration workspace tokens or secret links; - Instructor class capabilities, Student class-session capabilities, or assignment-specific classroom workspace capabilities; +- the `INTAKE_ADMIN_TOKEN` Administration / Maintenance credential; - authorization headers; - production incident snapshots containing confidential data; - private participant identity data. The collaboration capability model treats possession of the correct secret as authorization. Classroom separates privileges across Instructor class authority, human admission code, Student class session, and assignment-specific Student workspace authority. Server code stores only hashes for high-entropy bearer capabilities and must not log raw capabilities or snapshots. +Administration / Maintenance is a separate privileged boundary documented in `docs/admin-maintenance.md`. `INTAKE_ADMIN_TOKEN` is environment-only, is never an Intake or Classroom credential, and is accepted only in the `Authorization` header for `/api/admin`. The browser may retain a successfully verified Admin token only in tab-scoped `sessionStorage` under `kt-admin-session-v1`; it must never enter `localStorage`, Intake persistence, files, summaries, templates, URLs, logs, analytics, or telemetry. Admin inventory never returns raw capabilities or Intake snapshots. Physical purge requires a short-lived signed server preview; changed activity/state invalidates the preview before deletion. Classroom-owned workspaces cannot be purged independently of their owning class. + + Same-device Student resume stores the high-entropy **Student class-session capability** under `kt-classroom-student-session-v1` together with public class/participant/current-assignment context. The human join code is discarded after admission. The current assignment-specific workspace capability is **memory-only** and must be reacquired after reload or reassignment; it must not be written into the live resume envelope or URL. #328 intentionally rejects older pre-production Student session-envelope formats. No Student resume envelope may enter Intake state, exports, summaries, templates, analytics, logs, or error telemetry. For same-device Instructor resume, the browser retains the Instructor class capability under `kt-classroom-instructor-session-v1` together with public class metadata, the human Student join code, and the last selected public workspace ID. This credential may administer/list/observe only its represented class through Instructor classroom APIs. It must never enter Intake state, exports, summaries, templates, URLs, analytics, logs, error telemetry, or `collaboration_workspace_capabilities`. From f501c959fa6b6ba67197d3f918c9817ac72606a1 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:19:03 -0400 Subject: [PATCH 15/29] Document Admin maintenance runtime and environment --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c0b46aa..23926ba 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ KT Intake is a browser-first Kepner–Tregoe (KT) incident workbook designed for ## Quickstart - Clone or download this repository. - Open `index.html` in any modern browser. Standalone use remains local-first and does not depend on the classroom backend. -- A genuinely new browser asks whether to **Work independently**, **Join a class**, or **Teach a class**. Existing saved Intakes and existing `?workspace=` collaboration links migrate silently to **Standalone** so the new chooser does not interrupt established workflows. +- A browser with no current experience preference asks whether to **Work independently**, **Join a class**, or **Teach a class**. Existing explicit `?workspace=` collaboration links still route to **Standalone**; saved Intake data by itself no longer silently chooses an experience. - The selected experience resumes from the separate `kt-experience-role-v1` preference. Intake work itself still loads from `kt-intake-full-v2`, with action plans under `kt-actions-by-analysis-v1`. - Use the header controls to **Save to File** (exports a JSON snapshot) or **Load from File** (imports a previously saved snapshot) when you need to move an intake between browsers or machines. - Open the shared resource drawer to work with curated material. **Standalone** receives public Standard Templates only. Connected **Students** receive Standard Templates plus Classroom-authorized Case Studies; connected **Instructors** receive authorized teaching Case Studies. The rotating Case Study mode password remains a learning/progression control, not authentication. @@ -29,6 +29,10 @@ AI contributors should run the following commands (or manual preview) whenever t | `npm run quality` | Before marking any pull request ready. | Canonical repository gate: lockfile, repo doctor, domain guards, lint, generated-file freshness, protected-case boundary, storage docs, and the full test suite. See [`docs/REPOSITORY-OPERATIONS.md`](docs/REPOSITORY-OPERATIONS.md). | | `npm run update:storage-docs` / `npm run check:storage-docs` | Run `update` whenever you alter persisted schema, then `check` before pushing. | Keeps [`docs/storage-schema.md`](docs/storage-schema.md) and [`docs/storage-schema.appendix.md`](docs/storage-schema.appendix.md) synced with new keys or shapes. | +## Administration environment + +Production Administration / Maintenance is fail-closed until a 43-character URL-safe 256-bit `INTAKE_ADMIN_TOKEN` is configured in the Vercel project environment. The value is server-only and must not be committed to this repository. See [`docs/admin-maintenance.md`](docs/admin-maintenance.md). + ## Entry Point & Boot Logic - `index.html` declares the full UI layout and loads the JavaScript bundle via ``. - `main.js` waits for `DOMContentLoaded`, then calls `boot()`. This bootstraps every feature in order: @@ -62,6 +66,7 @@ See [`docs/architecture-overview.md`](docs/architecture-overview.md) for the boo | `src/coachableFields.js` | Backward-compatible coaching facade over `src/intakeTargets.js`; preserves existing coaching export names and stored target IDs without owning a second registry. | | `src/classroomCoaching.js` | Instructor coaching controls and Student read-only feedback UI backed by the separate Classroom coaching API. | | `src/classroomCaseStudies.js` | In-memory authorized Classroom Case Study catalog/payload client. It receives active Student/Instructor capabilities from their lifecycle controllers and never persists them. | +| `src/adminMaintenance.js` | Privileged Administration / Maintenance UI. Keeps the verified Admin key only in tab-scoped `sessionStorage`, renders lifecycle inventory, performs Instructor recovery, and enforces preview-before-purge through `/api/admin`. | | `main.js` | Entry point that imports every module, wires shared events, and runs `boot()`. | ### Storage keys @@ -71,6 +76,7 @@ See [`docs/architecture-overview.md`](docs/architecture-overview.md) for the boo - `kt-classroom-student-session-v1`: Student same-device resume key. Its current v2 envelope contains the stable Student class-session capability plus public class/participant/current-assignment context; the assignment-specific workspace capability remains memory-only and is reacquired after reload or reassignment. Older envelope formats are intentionally unsupported before production. The envelope never enters Intake exports/summaries/templates. - `kt-classroom-student-local-recovery-v1`: Local recovery snapshot captured immediately before joining a class so **Leave class** can restore the prior local Intake. It is separate from the active Intake snapshot and classroom credentials. - `kt-classroom-instructor-session-v1`: Local-only Instructor same-device resume envelope containing the Instructor class capability, public class metadata, and the last selected public workspace ID. It is never collected into Intake state, files, summaries, templates, or Student workspace credentials. +- `kt-admin-session-v1`: Tab-scoped Administration / Maintenance credential envelope stored in `sessionStorage` only after successful server verification. It is never written to localStorage or Intake state and disappears when the tab session ends or Admin signs out. Coaching feedback is server-side Classroom data, not a local Intake storage key. It lives in `classroom_coaching_feedback` and is deliberately excluded from `kt-intake-full-v2`, Save/Load, templates, summaries, and collaboration snapshot revisions. @@ -78,6 +84,8 @@ Coaching feedback is server-side Classroom data, not a local Intake storage key. Experience role is a product-level choice, not an Intake workflow mode. General / IT / Pharma / Major Incident remain controlled by `meta.intakeMode`; Standalone / Student / Instructor are controlled separately by `src/experienceRoles.js` and `src/experienceRoleController.js`. +**Administration / Maintenance is not an experience role.** It is a separate privileged utility entered from the chooser or View menu and authorized only by the server-configured `INTAKE_ADMIN_TOKEN`. See [`docs/admin-maintenance.md`](docs/admin-maintenance.md). + - **Standalone** exposes the normal Intake and current collaboration behavior. Its resource drawer contains **Templates only**. - **Student** joins with a display name and one human class code. An Instructor share/QR link may prefill that same code through a client-only `#join=` fragment; the fragment is consumed locally and never replaces normal server admission. Admission creates a stable high-entropy Student class-session capability; the learner may remain **Waiting / unassigned** with no workspace edit authority until the Instructor assigns a team or individual workspace. Once assigned, the browser exchanges the class session for a fresh assignment-specific editable workspace capability, keeps that workspace capability memory-only, and attaches it to the existing collaboration engine without entering the URL. Reassignment disconnects old authority before the Student enters the destination team's existing Intake; unassign returns the Student to Waiting. Connected Students see class/workspace/identity context, public **Templates**, protected Classroom Case Studies, and read-only Instructor coaching including optional notes and **Changed since review**. On narrow screens the Class, Case, Team, and Notes secondary surfaces default compact but remain one-action accessible; those collapse states are presentation-only. Switching away pauses live classroom sync while preserving class resume; **Leave class** clears resume and restores the local Intake captured before joining. - **Instructor** uses **Start a class**, receives one human Student join code, and can copy the code, share a fragment-only join link, or show a fully local QR for that same safe link. The Instructor sees Waiting/assigned participants, creates team or individual workspaces, and assigns/reassigns/unassigns Students through accessible selectors. The Instructor capability is retained locally for same-device resume; lost cross-device authority will be handled by the separately authorized Administration / Maintenance experience in #329 rather than a public bearer-code form. The dashboard can rapidly switch into a **live read-only** view of each Student/team Intake and provide field-level coaching. Observation uses the normal Intake renderer but server authorization keeps the Instructor credential outside Student edit capability. The same Instructor class capability authorizes protected teaching Case Studies. The Class rail defaults compact on narrow screens without changing session authority. Switching away pauses observation while preserving same-device class resume; **Leave class** clears the Instructor resume and restores the instructor's prior local Intake. From e2675b5fe3a325476a7f98982a4eb32693652bee Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:19:23 -0400 Subject: [PATCH 16/29] Add Admin maintenance API guardrails --- api/AGENTS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/api/AGENTS.md b/api/AGENTS.md index 448ba08..d9fe563 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -15,6 +15,24 @@ These rules apply to all server-only modules below `api/`. - Responses containing capabilities or private Intake data use `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. - Display names are presentation metadata, never identity or authorization. +## Administration / Maintenance boundary + +Read `docs/admin-maintenance.md` before editing `api/admin.js`, `api/_admin.js`, `INTAKE_ADMIN_TOKEN`, maintenance inventory, lifecycle recovery, or purge behavior. + +Rules: + +- Administration is not Standalone, Student, or Instructor authority and must never be accepted on normal Classroom/collaboration endpoints. +- `INTAKE_ADMIN_TOKEN` is environment-only and must never be stored in the database, browser localStorage, Intake state, URLs, logs, analytics, or test snapshots. +- `/api/admin` is the single deployable Admin entrypoint; do not create a directory of Admin function wrappers. +- Admin responses are always `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. +- Inventory may return non-secret public maintenance IDs and lifecycle metadata, but never raw capability values/hashes or Intake snapshots. +- Instructor recovery rotates to a new capability and returns that raw value once; historical bearer credentials are not recoverable. +- Physical purge is preview-first. The signed preview must be short-lived, commit must re-read/revalidate the exact candidate plan, and a changed plan returns conflict rather than silently widening deletion. +- Recent collaboration presence is activity. Never purge an apparently idle workspace based only on old snapshot/update time when recent presence exists. +- Class-owned collaboration workspaces are physically deleted only with their owning class. +- Bulk purge SQL must guard the complete requested candidate set before mutation and preserve FK cascade integrity. +- Do not introduce autonomous scheduled deletion until a separate roadmap item explicitly approves it. + ## Classroom capability boundaries Read `docs/classroom-api.md` and `docs/classroom-architecture.md` before modifying classroom endpoints. From d5262a20f50b2705a567c45bdb305d19c8b14aa0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:19:28 -0400 Subject: [PATCH 17/29] Map Admin maintenance modules for cold starts --- docs/AI-ONBOARDING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/AI-ONBOARDING.md b/docs/AI-ONBOARDING.md index a8f03a2..46bc50d 100644 --- a/docs/AI-ONBOARDING.md +++ b/docs/AI-ONBOARDING.md @@ -22,6 +22,7 @@ For any work in the Standalone / Student / Instructor program (#288), also read | `src/experienceRoles.js` | Canonical Standalone / Student / Instructor IDs, labels, and declarative product-surface policy. | | `src/experienceRoleController.js` | First-run chooser, current-format role resume/switching, join-link routing, and local-only `kt-experience-role-v1` preference. Pre-production raw-role and implicit saved-Intake migration compatibility is intentionally removed. | | `src/classroomStudent.js` | One-code Student admission, Waiting/own-status polling, current class-session resume, assignment-specific memory-only workspace access/reassignment/unassign, and coaching/resource lifecycle hooks. | +| `src/adminMaintenance.js` | Administration / Maintenance dialog, tab-scoped Admin credential envelope, lifecycle inventory/filtering, recovery display, and signed preview/commit UX. It never sets an Intake experience role. | | `src/classroomInstructor.js` | Start Class, human join-code display, same-device Instructor resume, live roster/team assignment management, read-only observation, and coaching lifecycle hooks. | | `src/coachableFields.js` | Stable coaching target IDs and versioned field fingerprints; DOM placement is deliberately separate from persistence identity. | | `src/classroomCoaching.js` | Instructor coaching controls and Student read-only feedback rendering through the separate coaching API. | @@ -37,6 +38,7 @@ For any work in the Standalone / Student / Instructor program (#288), also read | `src/summary.js` | `generateSummary()`, `setSummaryStateProvider()`, helpers that compose both clipboard output and AI prompts. | | `src/toast.js` | `showToast()` for lightweight notifications reused by comms and bootstrapping. | `api/_workspace.js` | Existing collaboration persistence/handlers plus explicitly edit-capable workspace aliases. Only `classroom-student` aliases may resolve through legacy read/write collaboration endpoints. | +| `api/_admin.js`, `api/admin.js` | Server-only Admin authorization, lifecycle inventory, signed purge previews, class revoke, Instructor authority rotation, and guarded purge. Requires `INTAKE_ADMIN_TOKEN`; read `docs/admin-maintenance.md`. | | `api/_classroom.js` | Class/live-participant schema and capabilities, one-code admission, own-status/current-access exchange, Instructor assign/reassign/unassign, class-scoped observation, legacy admission compatibility, and separate coaching APIs. | | `api/_protectedCaseStudies.js` | Server-only protected Case Study catalog/payload delivery. Instructor access is class-scoped; Student access resolves only the currently active Classroom workspace authority. #313 staged release must extend this boundary rather than shipping unreleased case content to the browser. | From ea6b01495814af50f920a7219f157578dd075a7b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:20:59 -0400 Subject: [PATCH 18/29] Add Admin maintenance to architecture map --- docs/architecture-overview.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 950bf5e..7b8d710 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -7,7 +7,7 @@ This document explains how the intake app boots, which modules own which DOM reg `main.js` is the entry point referenced by `index.html`. Once `DOMContentLoaded` fires it runs `boot()`, which performs the following steps: 1. **Expose shared utilities** – assigns `window.showToast` first so modules and tests can emit notifications even before the rest of the UI finishes initializing. -2. **Restore the product experience role** – `initExperienceRoleController()` reads the separate `kt-experience-role-v1` preference, silently migrates existing saved Intakes / `?workspace=` links to Standalone, or opens the first-run chooser for a genuinely new browser. It applies only broad `data-experience-surface` visibility and never mutates Intake state. +2. **Restore the product experience role** – `initExperienceRoleController()` reads the separate `kt-experience-role-v1` preference. An explicit `?workspace=` link routes to Standalone, a valid saved preference resumes its role, and otherwise the chooser opens. Saved Intake data by itself does not choose a role. The controller applies only broad `data-experience-surface` visibility and never mutates Intake state. 3. **Configure the KT table module** – calls `configureKT()` with callbacks owned by other modules: - `autoResize` keeps textarea heights in sync with content. - `onSave` points to `saveAppState()` so KT edits trigger persistence. @@ -36,6 +36,7 @@ This document explains how the intake app boots, which modules own which DOM reg | --- | --- | --- | | `main.js` | Orchestrates boot, registers DOM listeners, wires file transfer + global shortcuts, exposes fallbacks for legacy integrations. | Relies on every feature module’s public API, but only coordinates them—it never reaches into DOM anchors it does not own. | | `src/experienceRoles.js` & `src/experienceRoleController.js` | Define Standalone/Student/Instructor independently from Intake modes, persist the local-only role preference, own the role chooser, and project broad product surfaces. | Own `kt-experience-role-v1`, `[feature:experience-role]`, `[feature:experience-role-switch]`, Student notice, and Instructor shell. Experience state never enters `appState`. | +| `src/adminMaintenance.js` | Owns the privileged Administration / Maintenance overlay, tab-scoped Admin session envelope, lifecycle filters, Instructor recovery display, and preview-first destructive UX. | Calls only `/api/admin`; it never sets `data-experience-role` and never enters Intake persistence. | `src/preface.js` | Manages `[section:preface]` + `[section:impact]` inputs, detection chips, mirror sync, and tokens such as `{OBJECT}` and `{DEVIATION}`. | Supplies `autoResize`, `updatePrefaceTitles`, `startMirrorSync`, `setBridgeOpenedNow`, `getPrefaceState`, `getObjectFull`, `getDeviationFull`. Receives `onSave` from `main.js`. | | `src/kt.js` | Owns `[section:table]`: builds the IS/IS NOT table, possible-cause cards, focus modes, and cause evidence previews. | Accepts callbacks from Preface & Toast via `configureKT()`. Provides `exportKTTableState`, `importKTTableState`, `getPossibleCauses`, and other helpers consumed by `appState` & summary modules. | | `src/comms.js` & `src/commsDrawer.js` | Handle the communications drawer DOM (`#commsDrawer` + backdrop), cadence inputs, log visibility, and logging buttons. | `initializeCommunications()` receives `onSave` + `showToast`. `initStepsFeature({ onLog: logCommunication })` lets checklist actions add log entries. `main.js` uses `getCommunicationElements()` to wire buttons. | @@ -57,6 +58,7 @@ This document explains how the intake app boots, which modules own which DOM reg | `src/classroomDebriefModel.js` | Pure #319 Instructor class-debrief read model that projects authorized current/checkpoint snapshots through the universal target registry and derives neutral readiness/coaching-change metadata. | DOM/network/persistence-free; returns comparison-safe projections only, keeps Possible Cause instances workspace-local, omits coaching notes/scores, and never mutates Intake/coaching/collaboration state. | | `src/classroomDebriefComparison.js` | Instructor-only #319 presentation controller for class progress and cross-workspace semantic target comparison. | Consumes `/api/classes/debrief`, stores comparison selection in memory only, renders desktop matrix/mobile stacked cards, and delegates drill-down to the existing Instructor observer; never applies aggregate snapshots or persists comparison state. | | `api/_workspace.js` | Owns the existing Neon collaboration schema/handlers plus edit-capable alias resolution for classroom Student tokens and the internal-ID read helper used after Instructor authorization. | Legacy primary tokens remain valid. Alias resolution remains restricted to explicit edit kinds (`classroom-student`); Instructor observation never resolves through the editable alias path. | +| `api/_admin.js` & `api/admin.js` | Own server-authorized lifecycle inventory, Admin recovery, signed purge previews, and guarded physical cleanup. | `INTAKE_ADMIN_TOKEN` is environment-only; inventory excludes snapshots/capabilities and Class-owned workspaces purge only with their class. See `docs/admin-maintenance.md`. | | `api/_classroom.js`, `api/_protectedCaseStudies.js` & `api/classes/*` | Own class metadata/capabilities, workspace membership, observation/coaching authorization, and protected Case Study delivery. | Protected Case Studies use the server-only generated manifest: Instructor access is class-scoped, Student access is membership-bound, GET is metadata-only, and full payload selection is authenticated POST with private caching/referrer headers. See `docs/classroom-api.md`. | | `src/toast.js`, `src/versionStamp.js` | Provide shared UI niceties (toasts, footer stamp). | `showToast` is passed to modules that need user feedback; `initVersionStamp` is called once from `boot()`. | From 536e491fe57469418ec33732e764b886d55a7b9d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:21:04 -0400 Subject: [PATCH 19/29] Register Admin maintenance feature anchors --- docs/commenting-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md index e84cab2..c1a4f4e 100644 --- a/docs/commenting-guide.md +++ b/docs/commenting-guide.md @@ -45,6 +45,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist | `[feature:collaboration-presence]` | `index.html`, in the workspace dock immediately before Notes workspace | Shows the editable shared team name and recently active participants, plus a compact mobile Team card and the accessible start/join/edit identity dialog. | `src/collaboration.js`, `api/workspaces/presence.js` | `collaborationWorkspace`, `collaborationWorkspaceToggle`, `collaborationWorkspaceCompactSummary`, `collaborationParticipants`, `collaborationDialog`; mobile expand/collapse is presentation-only, local identity preference uses `kt-collaboration-profile-v1`, team metadata stays server-side, and neither presentation nor metadata enters intake snapshots. | | `[feature:templates-drawer]` | `index.html` header toolbar + Instructor shell launcher | Shared role-aware Templates / Case Studies drawer. Standalone renders public Standard Templates only; connected Student/Instructor roles receive the current authorized protected Case Study catalog in memory. Student fetches a protected payload only when applying it after the pedagogical unlock. | `src/templatesDrawer.js`, `src/templates.js`, `src/templateAvailability.js`, `src/classroomCaseStudies.js` | `templatesBtn`, `instructorTeachingResourcesBtn`, `templatesDrawer`, `templatesList`, `templatesModeSection`, `templatesAuthSection`, `templatesTeachingNotice`, `templatesDrawerFooter`; Classroom capability/catalog state is local-only and excluded from Intake persistence. | | `[feature:experience-role]` | `index.html`, immediately after the header | Accessible first-run/switching dialog for Standalone, Student, and Instructor. | `src/experienceRoles.js`, `src/experienceRoleController.js` | `experienceRoleGate`, `experienceRoleDialog`, `experienceRoleCancelBtn`; preference uses `kt-experience-role-v1` and is excluded from Intake state. | +| `[feature:admin-maintenance]` | `index.html`, immediately after the experience chooser | Privileged maintenance overlay for server lifecycle inventory, Instructor recovery, and preview-first cleanup. | `src/adminMaintenance.js`, `api/admin.js`, `api/_admin.js` | `adminMaintenanceGate`, `adminMaintenanceDialog`, `adminMaintenanceAuthForm`, `adminMaintenanceConsole`, `adminPurgePreview`, `adminRecoveryPanel`; Admin credential is tab-scoped under `kt-admin-session-v1` and excluded from Intake state. | | `[feature:experience-role-switch]` | `index.html`, View menu | Reopens the experience chooser without resetting Intake data. | `src/experienceRoleController.js`, `src/menuBar.js` | `experienceRoleMenuBtn`, `data-experience-role-label`; local-only, summary-excluded. | | `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available; valid `#join=` intents prefill this normal admission surface and are removed client-side. | `src/classroomStudent.js`, `src/classroomJoinLink.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; join intent contains no bearer capability and all controls remain local-only/summary-excluded. | | `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class; narrow screens default to a compact class summary. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassCompactSummary`, `studentClassContextToggle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; mobile expansion is presentation-only and no Class-context field is persisted in Intake state. | From 371886fb19d0332b4bd6d6f84c6c1ed7ba7b88a9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:21:30 -0400 Subject: [PATCH 20/29] Advance hardening plan to active Admin slice --- docs/preproduction-hardening.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/preproduction-hardening.md b/docs/preproduction-hardening.md index 5b9c7a7..73be73f 100644 --- a/docs/preproduction-hardening.md +++ b/docs/preproduction-hardening.md @@ -31,7 +31,7 @@ PRODUCTION SIMULATION ENABLEMENT #317 first production staged KT Case Study ``` -#328 comes first because #329 and #330 should be built against the final credential and persistence model rather than compatibility contracts we already intend to delete. +#328 is complete and published. #329 is now active on draft PR #333, with its canonical runtime contract in `docs/admin-maintenance.md`. #330 remains next after #329 so the startup hub can integrate the proven Admin entry instead of designing against a placeholder. ## End-state access model @@ -82,6 +82,8 @@ Do not remove transformations that remain part of the **current** data model mer ## Administration / Maintenance architecture +**Implementation status:** active in #329 / draft PR #333. The detailed implemented contract is `docs/admin-maintenance.md`; this section remains the roadmap-level summary. + Administration is a privileged maintenance surface, **not** a fourth Intake experience role. ### Entry From ce9cb560a36de4f243181ec4be4516bb333494f9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:21:35 -0400 Subject: [PATCH 21/29] Checkpoint active #329 Admin maintenance work --- docs/classroom-workstream.md | 93 +++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index ebc7290..902c4eb 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -6,9 +6,11 @@ This is the live restart document for the Classroom Experience program (#288). ## Current phase -The secure Classroom foundation (#288), live-class management (#312), staged-simulation engine (#313), universal target layer (#318), debrief comparison (#319), and join/mobile polish (#320) are **merged**. PR #326 merged #319 to `main` as `400a6c5ee0b879b4a36705be6d8af075bc89d391`; that exact SHA reached Vercel production **READY** and the post-deploy runtime-error scan was clean. PR #327 then merged the small-slice/slow-gate operating rules as `dce82b1ddb94ff926464af4a9b9d81704ba05a5a`. +The secure Classroom foundation (#288), live-class management (#312), staged-simulation engine (#313), universal target layer (#318), debrief comparison (#319), join/mobile polish (#320), and pre-production compatibility reset (#328) are **merged and published**. -Pre-production hardening is active. **#328** is implemented on `feature/preproduction-compat-reset` / draft PR #332 and is in final validation. The governing contract remains `docs/preproduction-hardening.md`. +#328 completed in PR #332, squash-merged to `main` as `8ad3eb2b80d080d244556b9280a0fd2f83565193`. The exact merged SHA reached Vercel production **READY**, GitHub Vercel status was success, and the post-deploy runtime-error scan was clean. + +**#329 Administration / Maintenance is the active implementation slice** on `feature/admin-maintenance` / draft PR #333. Canonical contracts: `docs/preproduction-hardening.md` and `docs/admin-maintenance.md`. ## Program issues @@ -30,9 +32,9 @@ Pre-production hardening is active. **#328** is implemented on `feature/preprodu | Universal Intake target identity | #318 | Complete | PR #325 merged as `ef694910...` | Shared semantic target projection and compatibility guards | | Class debrief comparison | #319 | Complete / published | PR #326 merged as `400a6c5e...` | 319A–F complete; exact merged-main production READY + runtime clean | | Join/mobile polish | #320 | Complete | PR #324 merged as `273ae643...` | Safe share/QR + compact mobile Class/Case/Team/Notes/Instructor chrome | -| Compatibility reset | #328 | **Implementation complete / validating** | Draft PR #332 | Current one-code/session model only; obsolete Classroom access/schema and pre-production save migrations removed | -| Admin / Maintenance | #329 | Planned / blocked by #328 | Open | Inventory, preview, revoke/purge stale Classroom + collaboration data | -| Startup experience hub | #330 | Planned / blocked by #328/#329 | Open | Explicit Continue / Work independently / Join / Run / Admin choices | +| Compatibility reset | #328 | Complete / published | PR #332 merged as `8ad3eb2b...` | Current one-code/session model only; obsolete Classroom access/schema and pre-production save migrations removed | +| Admin / Maintenance | #329 | **Active implementation** | Draft PR #333 / `feature/admin-maintenance` | Server-authorized inventory, recovery, signed preview, guarded purge, responsive maintenance UI | +| Startup experience hub | #330 | Planned / blocked by #329 | Open | Explicit Continue / Work independently / Join / Run / Admin choices | | Rich staged assets | #316 | Deferred until hardening complete | Open | Secure image/table/document-page delivery | | First production staged case | #317 | Deferred / blocked by #316 | Open | Source-faithful official case authoring/rehearsal | @@ -46,6 +48,7 @@ Pre-production hardening is active. **#328** is implemented on `feature/preprodu - Classroom credentials never belong in exported Intake state. - Case Study password is instructional gating, not authentication. - Protected Case Study metadata/payloads are server-gated, absent from public browser assets, and authored `templates/*.json` is excluded from Vercel deployment. +- Administration / Maintenance is a separate privileged utility, not a fourth experience role. It uses the environment-only `INTAKE_ADMIN_TOKEN`, tab-scoped `sessionStorage`, non-secret maintenance IDs, and signed preview-before-purge semantics documented in `docs/admin-maintenance.md`. See `docs/classroom-architecture.md` for the full contract. @@ -57,6 +60,7 @@ See `docs/classroom-architecture.md` for the full contract. - `docs/classroom-architecture.md`; - `docs/classroom-roadmap.md`; - `docs/preproduction-hardening.md` for #328–#330; + - `docs/admin-maintenance.md` for #329 Admin/recovery/retention/purge work; - `docs/classroom-api.md` for server/class work; - `api/AGENTS.md` for server/class work; - this file; @@ -71,39 +75,52 @@ See `docs/classroom-architecture.md` for the full contract. ## Active work -**#328 — Pre-production compatibility reset** is active on `feature/preproduction-compat-reset` / draft PR #332 and is at final validation. - -Frozen implementation checkpoint before this final ledger update: `2383bc2cb4c4fe2d3561a4e13a01b9d96ad1ff6e`. - -Completed: -- Student entry is display name + one human class code only; the legacy assignment-code disclosure and two-code client flow are removed. -- Student resume accepts only the current v2 class-session envelope. Assignment-specific workspace capability remains memory-only and is reacquired from the server. -- Instructor entry exposes Start Class + same-device resume only. The public **Open an existing class / Instructor access code** bearer-entry form is removed; future recovery belongs to #329 Administration / Maintenance. -- `POST /api/classes/join`, Student join-token issuance/rotation, workspace assignment-claim secrets, legacy membership authorization, and their Vercel route are removed. -- Existing pre-production databases explicitly drop `student_join_token_hash`, workspace `claim_token_hash`, and `classroom_memberships` during idempotent schema initialization. -- Current authorization remains human join code -> Student class-session -> current assignment-specific `classroom-student` workspace capability. Instructor observation/coaching remains class-scoped/read-only with respect to Student Intake. -- Intake serialization is current-version-only at `APP_STATE_VERSION = 3`; the historical migration registry is empty and old snapshot versions fail closed. -- Experience-role preference accepts only the current versioned envelope; saved Intake no longer silently infers Standalone. -- Load-from-File rejects unsupported snapshot versions with an explicit file-version/current-version message before normalization/application. -- All five authored Template/Case Study sources and both generated manifests use the canonical v3 Intake schema. -- Unit/API/browser fixtures have been converted to the current access/session model; retired browser journeys were consolidated into live-class coverage. -- Canonical API, architecture, security, onboarding, README, live-management, and storage-schema docs have been reconciled to the current model. - -Validation evidence: -- An earlier Template Manifest Guard run was green after canonical resource conversion; the final docs-only head still requires its own fresh required checks. -- The first full quality attempt reached **360 tests / 358 pass / 1 fail / 1 intentional skip**; the single failure was an obsolete revocation test boundary and was corrected to exercise current Student class-session 401/404 revocation. -- Branch-wide changed-fixture audit found no accidental v1/v2 Intake snapshots; the only old-version fixture remaining is the intentional rejection test. -- Runtime audit finds retired DB identifiers only inside the deliberate `DROP ... IF EXISTS` cleanup statements. -- Explicit compatibility shims for legacy containment values, P1/P2/P3 action priorities, raw experience-role preferences, old Student recovery envelopes, and cause hypothesis/summary fields are removed with rejection/current-contract coverage. -- PR #332 has **0 unresolved review threads**. -- Final exact-head CI / CodeQL / Dependency Review / Template Manifest Guard are still pending as of this checkpoint; do not claim them green until refreshed after this documentation-only commit. - -Exact next action: -1. refresh PR #332 exact head and required checks; -2. if CI fails, fix only the concrete failing current-contract test/gate and repeat; -3. once all required checks are green, update PR #332 with final evidence, mark it ready for review, verify 0 unresolved threads, and squash-merge the exact head; -4. verify merged `main` publication/runtime before closing #328; -5. #329 Administration / Maintenance is the next product slice after #328 is closed. +**#329 — Administration / Maintenance lifecycle cleanup** is active on `feature/admin-maintenance` / draft PR #333. + +Base: `main` at completed #328 merge `8ad3eb2b80d080d244556b9280a0fd2f83565193`. + +### 329A — server lifecycle boundary: complete + +- one deployable `api/admin.js` entrypoint backed by `api/_admin.js`; +- environment-only 43-character `INTAKE_ADMIN_TOKEN`; missing/malformed configuration fails closed; +- constant-time Admin credential comparison; no-store/no-referrer responses; +- collaboration workspaces gain a non-secret UUID `public_id` for maintenance identity; +- server inventory derives Class and collaboration activity from authoritative update + presence timestamps and returns no Intake snapshots or bearer capabilities; +- inventory includes class lifecycle, participant/workspace/presence/subordinate counts, current staged exercise context, workspace ownership, and derived idle age; +- Admin can close/revoke a class immediately; +- Admin can rotate/reissue active Instructor authority, returning the new raw capability once; +- physical cleanup is preview-first with a signed 10-minute exact candidate plan; +- commit re-reads inventory, recomputes candidate fingerprints, and fails with conflict if activity/state changed; +- bulk class/workspace delete SQL rechecks the complete requested set before mutation; +- independent collaboration cleanup cannot delete a Classroom-owned workspace; +- class purge deletes class-owned collaboration workspaces first and relies on FK cascades for capabilities/presence/Classroom subordinate data; +- Admin adds exactly one deployable Vercel function, taking the expected repository count from 5 to 6 while remaining below the conservative limit of 12; +- deterministic `tests/admin-api.unit.test.mjs` covers configuration/auth rejection, private headers, no secret leakage, recent-presence protection, class-owned isolation, preview/commit matching, stale preview rejection, preview expiry, terminal-only single-class purge, class close, and Instructor rotation. + +### 329B — browser maintenance console: implementation complete + +- secondary **Administration / Maintenance** entry exists in the experience chooser and View menu without creating a new experience role; +- verified Admin key is retained only in tab-scoped `sessionStorage` under `kt-admin-session-v1`; +- UI provides refresh, sign out, search/filtering, lifecycle/ownership filters, selectable idle threshold, class close, Instructor reissue, single/bulk preview, exact confirmation, and responsive mobile layout; +- recovery panel displays the newly rotated Instructor capability without persisting it; +- Escape handling and keyboard focus containment are implemented; +- `tests/e2e/admin-maintenance.spec.mjs` covers Admin/role isolation + no localStorage leakage + axe scan, exact destructive previews protecting active/class-owned data, and non-persistence of reissued Instructor credentials. + +### Documentation / external dependency + +- canonical contract: `docs/admin-maintenance.md`; +- root/server agent guidance, SECURITY, README, AI onboarding, architecture map, commenting guide, and pre-production hardening docs are being reconciled in this PR; +- **production remains intentionally fail-closed until `INTAKE_ADMIN_TOKEN` is configured in Vercel**. Do not put that value in GitHub or repository files. + +### Exact next action + +1. refresh PR #333 exact head and inspect required checks; +2. fix only concrete quality/unit/browser/security failures; +3. finish any remaining documentation drift and update PR #333 with exact validation; +4. configure a fresh production `INTAKE_ADMIN_TOKEN` through Vercel environment settings without committing/logging it; +5. rerun/verify final required checks, mark PR #333 ready, and squash-merge only on an unchanged green head; +6. verify exact merged-main Vercel production **READY**, exercise Admin authorization/inventory on production without destructive cleanup, scan runtime errors, close #329; +7. proceed to #330 startup experience hub. ### Historical checkpoint ledger From 63bd9e066eae243d689d3458fa1be8b40613ac5d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:22:43 -0400 Subject: [PATCH 22/29] Recognize session-only UI in state guards --- scripts/ensure-summary-integrations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ensure-summary-integrations.js b/scripts/ensure-summary-integrations.js index d478b80..cbdc5b9 100644 --- a/scripts/ensure-summary-integrations.js +++ b/scripts/ensure-summary-integrations.js @@ -105,7 +105,7 @@ function isExperienceSurfaceContainerOnly(line) { const addedInterfaceLines = getInterfaceAdditions(); const newControlLines = addedInterfaceLines.filter((line) => { if (isExperienceSurfaceContainerOnly(line)) return false; - if (/data-summary=["']exclude["']/.test(line) || /data-persistence=["']local-only["']/.test(line)) return false; + if (/data-summary=["']exclude["']/.test(line) || /data-persistence=["'](?:local-only|session-only)["']/.test(line)) return false; return /<(input|textarea|select|option)\b/i.test(line) || /class="[^"]*(field|select|picker|dropdown)[^"]*"/i.test(line); }); From 187d015c68b877c430cbbcff3a0b5f64a4a99728 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:22:48 -0400 Subject: [PATCH 23/29] Recognize session-only UI in state guards --- scripts/ensure-persistence-integrations.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ensure-persistence-integrations.js b/scripts/ensure-persistence-integrations.js index 48e022c..9f20846 100644 --- a/scripts/ensure-persistence-integrations.js +++ b/scripts/ensure-persistence-integrations.js @@ -105,7 +105,7 @@ function isExperienceSurfaceContainerOnly(line) { const addedInterfaceLines = getInterfaceAdditions(); const triggerLines = addedInterfaceLines.filter((line) => { if (isExperienceSurfaceContainerOnly(line)) return false; - if (/data-persistence=["']local-only["']/.test(line)) return false; + if (/data-persistence=["'](?:local-only|session-only)["']/.test(line)) return false; return ( /<(input|textarea|select|option|button|label)\b/i.test(line) || /class="[^"]*(field|select|picker|dropdown|control)[^"]*"/i.test(line) || From ddae8a687e17af435e8223507c8c5e1a9e12e899 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:22:53 -0400 Subject: [PATCH 24/29] Document session-only Admin controls --- docs/summary-style-checklist.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/summary-style-checklist.md b/docs/summary-style-checklist.md index 8549d44..2f7e29a 100644 --- a/docs/summary-style-checklist.md +++ b/docs/summary-style-checklist.md @@ -12,3 +12,10 @@ Run `npm run verify:summary` to enforce this checklist. The guard fails when new ## Presentation-only experience surfaces Containers marked only with `data-experience-surface` control Standalone / Student / Instructor visibility and do not capture user data. The summary and persistence diff guards intentionally ignore those container-only additions. If the same added line introduces a real `input`, `textarea`, `select`, `option`, `button`, or `label`, normal guard enforcement still applies. + +## Privileged session-only controls + +Administration / Maintenance controls use `data-persistence="session-only"` because they are outside the Intake data model and must never enter local Intake persistence, templates, file exports, or Copy & Paste Summary output. + +The summary and persistence guards treat both `local-only` and `session-only` controls as explicitly non-Intake state. Admin credentials, lifecycle filters, purge thresholds, preview confirmation controls, and recovery values therefore remain excluded from incident summaries by design. They still require normal accessible labels and shared visual styling. + From 2556616b055f08734753a64fcbf83b8015e2a720 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:23:31 -0400 Subject: [PATCH 25/29] Fix Admin auth visibility and focus return --- src/adminMaintenance.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/adminMaintenance.js b/src/adminMaintenance.js index ba751b8..bdcd82a 100644 --- a/src/adminMaintenance.js +++ b/src/adminMaintenance.js @@ -91,7 +91,7 @@ function setBusy(busy) { } function showAuth() { - const auth = element('adminMaintenanceAuth'); + const auth = element('adminMaintenanceAuthForm'); const consolePanel = element('adminMaintenanceConsole'); if (auth) auth.hidden = false; if (consolePanel) consolePanel.hidden = true; @@ -103,7 +103,7 @@ function showAuth() { } function showConsole() { - const auth = element('adminMaintenanceAuth'); + const auth = element('adminMaintenanceAuthForm'); const consolePanel = element('adminMaintenanceConsole'); if (auth) auth.hidden = true; if (consolePanel) consolePanel.hidden = false; @@ -166,7 +166,7 @@ function metaRow(label, value) { } function actionButton(label, onClick, { danger = false } = {}) { - const button = documentRef.createElement('button'); + const button = documentRef.createElement('button'); // data-persistence="session-only" data-summary="exclude" button.type = 'button'; button.className = danger ? 'btn-secondary admin-maintenance__danger' : 'btn-secondary'; button.textContent = label; @@ -654,7 +654,9 @@ function bindControls() { documentRef.querySelectorAll('[data-open-admin-maintenance]').forEach(button => { if (button.dataset.adminMaintenanceBound === 'true') return; button.dataset.adminMaintenanceBound = 'true'; - button.addEventListener('click', () => openAdminMaintenance({ returnFocus: button })); + const panel = button.closest?.('.menu-panel'); + const trigger = panel?.id ? documentRef?.querySelector(`[data-menu-target="${panel.id}"]`) : null; + button.addEventListener('click', () => openAdminMaintenance({ returnFocus: trigger || button })); }); element('adminMaintenanceCloseBtn')?.addEventListener('click', closeAdminMaintenance); From eaaca33c33ad900ad77c15ce262f139530b38db9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:23:52 -0400 Subject: [PATCH 26/29] Mark Admin controls as session-only --- index.html | 84 +++++++++++++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/index.html b/index.html index d3178c4..f2cfa9c 100644 --- a/index.html +++ b/index.html @@ -305,7 +305,7 @@

How are you using Intake?

Administration / Maintenance

Inspect server-side Classroom and collaboration lifecycle data, recover Instructor authority, and preview cleanup before anything is permanently deleted.

- + @@ -315,11 +315,11 @@

Administration / Maintenance

Admin access required

The Admin key is separate from Student and Instructor access. After successful verification it is retained only for this browser tab and is cleared when you sign out or close the tab.

-
- - +
+ +
- +
-
@@ -355,20 +355,20 @@

Classes

0 of 0
-
- - +
+ +
-
- - + + + +
- +
@@ -383,20 +383,20 @@

Collaboration workspaces

0 of 0
-
- - +
+ +
-
- - + + + +
- +
@@ -410,8 +410,8 @@

Confirm purge

    The server will re-check this exact preview before deletion. If activity or state changed, the purge is rejected and must be previewed again.

    - - + +
    @@ -419,11 +419,11 @@

    Confirm purge

    One-time recovery value

    New Instructor access

    Copy this value now and provide it securely to the Instructor. The previous Instructor credential stopped working when this one was created. Intake does not store this raw value.

    - +
    - - + +
    From 3a8881e60322bddea069a1a2aed6f2236ed4e21c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:24:17 -0400 Subject: [PATCH 27/29] Exclude Admin recovery value from Intake state --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index f2cfa9c..7f7916f 100644 --- a/index.html +++ b/index.html @@ -420,7 +420,7 @@

    Confirm purge

    New Instructor access

    Copy this value now and provide it securely to the Instructor. The previous Instructor credential stopped working when this one was created. Intake does not store this raw value.

    - +
    From 1ffd2db95ba535efa538c4505f0c73caf09f1f47 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:19 -0400 Subject: [PATCH 28/29] Advance roadmap to active Admin lifecycle --- docs/classroom-roadmap.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md index 83c6f76..0097613 100644 --- a/docs/classroom-roadmap.md +++ b/docs/classroom-roadmap.md @@ -240,13 +240,13 @@ Canonical contract: `docs/preproduction-hardening.md`. ### P0 — pre-production hardening -**#328 — Pre-production compatibility reset** +**#328 — Pre-production compatibility reset — COMPLETE** -Remove the legacy Student two-code access path, the public Instructor access-code recovery form, and obsolete save/local compatibility migrations that exist only because of pre-production development history. Keep the current one-code Student admission + stable class-session model, current same-device Instructor resume, and intentional Standalone collaboration. This is explicitly allowed to be breaking because there are no production users to migrate. +Merged via PR #332 as `8ad3eb2b80d080d244556b9280a0fd2f83565193`. Removed the legacy Student two-code access path, the public Instructor access-code recovery form, and obsolete save/local compatibility migrations that exist only because of pre-production development history. Keep the current one-code Student admission + stable class-session model, current same-device Instructor resume, and intentional Standalone collaboration. This is explicitly allowed to be breaking because there are no production users to migrate. -**#329 — Administration / Maintenance lifecycle cleanup** +**#329 — Administration / Maintenance lifecycle cleanup — ACTIVE** -Add a separately authorized maintenance surface that inventories Classroom and Standalone collaboration data, derives idle/expired state from server timestamps + recent presence, previews destructive cleanup, and safely purges selected or bulk stale records. Administration is not a fourth Intake experience role. It also becomes the intentional recovery path for rotating/reissuing Instructor authority after #328 removes the public Instructor access-code form. +Draft PR #333 implements the canonical contract in `docs/admin-maintenance.md`: a separately authorized maintenance surface that inventories Classroom and Standalone collaboration data, derives idle/expired state from server timestamps + recent presence, previews destructive cleanup, and safely purges selected or bulk stale records. Administration is not a fourth Intake experience role. It also becomes the intentional recovery path for rotating/reissuing Instructor authority after #328 removes the public Instructor access-code form. **#330 — Startup experience hub + explicit resume** @@ -278,7 +278,7 @@ PRODUCTION SIMULATION ENABLEMENT #317 first production staged KT Case Study ``` -**Current product decision:** begin with **#328** after this roadmap/hygiene checkpoint is merged. Do not start #316/#317 first; their architecture should be built on the simplified credential, persistence, maintenance, and startup contracts. +**Current product decision:** #328 is complete. **#329 Administration / Maintenance is active on draft PR #333**; complete and production-verify it next, then proceed to #330. Do not start #316/#317 first; their architecture should be built on the finalized compatibility, maintenance, and startup contracts. ## Stacked PR rules From 68907836d1e720a57fb9026a29ed3d0929ab2b87 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:24 -0400 Subject: [PATCH 29/29] Link Classroom architecture to Admin maintenance contract --- docs/classroom-architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/classroom-architecture.md b/docs/classroom-architecture.md index 305367f..545237e 100644 --- a/docs/classroom-architecture.md +++ b/docs/classroom-architecture.md @@ -300,11 +300,11 @@ The repository is still pre-production and has no external production users to m The approved target architecture is defined in `docs/preproduction-hardening.md` and issues #328–#330: - #328 is the compatibility reset: one-code Student admission/class-session authority is the only Classroom Student model; public Instructor bearer-code recovery is removed; only the current Intake save schema is accepted; -- #329 adds separately authorized Administration / Maintenance inventory, recovery, and preview-first stale-data cleanup; +- #329 implements separately authorized Administration / Maintenance inventory, Instructor recovery, and signed preview-first stale-data cleanup; the detailed runtime contract is `docs/admin-maintenance.md`, and Admin is not an experience role; - #330 replaces implicit role restoration with explicit Continue / Work independently / Join a class / Run a class startup choices; - #316/#317 resume only after that hardening sequence. -On the #328 implementation branch, obsolete access/schema/save compatibility is removed rather than hidden. Future work must not recreate it as a recovery mechanism; recovery belongs to #329. +#328 is merged and published: obsolete access/schema/save compatibility is removed rather than hidden. Future work must not recreate it as a recovery mechanism; Instructor recovery belongs to the separate #329 Admin boundary. ## Migration philosophy