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,
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) ||
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);
});
diff --git a/src/adminMaintenance.js b/src/adminMaintenance.js
new file mode 100644
index 0000000..bdcd82a
--- /dev/null
+++ b/src/adminMaintenance.js
@@ -0,0 +1,715 @@
+/**
+ * @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('adminMaintenanceAuthForm');
+ 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('adminMaintenanceAuthForm');
+ 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'); // data-persistence="session-only" data-summary="exclude"
+ 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';
+ 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);
+ 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)
+ };
+}
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;
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');
+});
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([]);
+});
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',