feat(server): /svc/sessions names who has which project open

A grant lasts the session's school day; a connection ends with the tab.
The dashboard needs the second to end a teacher's write share that holds
for one session (F7.1, "Mitarbeiten" in "Alle Projekte").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:18:45 +00:00
co-authored by Claude Opus 5.5
parent 15498a2bed
commit cc5e07f1a6
2 changed files with 39 additions and 3 deletions
+20 -2
View File
@@ -6,7 +6,8 @@
// a running simulation stops, what it left written back), end a person's
// grant to a project or all their sessions (whoever is connected, to the
// document or the room, is let go), and list who is signed in to what (the
// dashboard checks it against its shares). FILIUS files (F6.1,
// dashboard checks it against its shares, and ends a teacher's share for one
// session when no tab of theirs has the project open). FILIUS files (F6.1,
// transfer.ts): make a project from one of FILIUS' examples or from a .fls
// the dashboard sends, copy a project (as it is now, also while people work
// in it), and give a project as a .fls (with what it could not hold, as
@@ -97,6 +98,23 @@ function uuid(value: unknown, code: string): string {
const contextOf = (connection: { context: unknown }) => connection.context as ConnectionContext;
/**
* Who has which project open right now — connected to its document, not only
* granted: a grant lasts the session's school day, a connection ends with the
* tab. The dashboard ends a teacher's write share for one session by it.
* Once per person and project, sorted.
*/
function openDocuments(sync: Sync): { readonly user: string; readonly project: string }[] {
const seen = new Map<string, { readonly user: string; readonly project: string }>();
for (const [project, document] of sync.hocuspocus.documents) {
for (const connection of document.getConnections()) {
const user = contextOf(connection)?.person?.user;
if (typeof user === 'string') seen.set(`${user} ${project}`, { user, project });
}
}
return [...seen.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, open]) => open);
}
export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise<void> {
const send = (status: number, value: object) => {
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
@@ -234,6 +252,6 @@ async function route(request: IncomingMessage, path: string, { store, sessions,
log.info('svc.revoked', { user, ...(only === undefined ? {} : { project: only }), revoked, disconnected });
return [200, { revoked, disconnected }];
}
if (request.method === 'GET' && path === '/svc/sessions') return [200, await sessions.active()];
if (request.method === 'GET' && path === '/svc/sessions') return [200, { ...(await sessions.active()), open: openDocuments(sync) }];
throw new HttpError(404, 'notFound');
}