Replace the Schulcloud token without a restart
A token lasts 30 days and only a browser login yields one — the account is federated, so the server cannot mint it. Replacing it meant editing .env and recreating the container, every month. `schulcloud token set` (a hidden prompt, or piped input) and a /token page both send it to PUT /api/token. The server checks it with Schulcloud first — well-formed, unexpired, still logged in, the same account — then swaps it into the config every request reads, restarts the keepalive and saves it in STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A refused paste changes nothing, and the token is never logged. The keepalive's pings carry a generation, so a 401 for the old token that arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami and the log report the expiry and warn a week ahead. Found on the way: a host that is off for more than two hours loses the session however long the token has left — this machine lost it overnight — which is what the always-on Pi is for. 174 tests. Smoke 72/72 on the local instance, and a real swap verified end to end there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { basename, dirname, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { ApiClient, ApiError } from '../cli/client.ts';
|
||||
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
|
||||
import { readHidden, readPiped } from '../cli/prompt.ts';
|
||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||
@@ -26,6 +27,8 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
|
||||
schulcloud get <fileId> [--out <path>]
|
||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||
schulcloud refresh [--course <id>] [--force]
|
||||
schulcloud token when the server's Schulcloud token expires
|
||||
schulcloud token set hand the server a fresh one (paste, or pipe it in)
|
||||
|
||||
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||
|
||||
@@ -71,6 +74,8 @@ async function main(argv: string[]): Promise<number> {
|
||||
return refresh(flags);
|
||||
case 'fs':
|
||||
return fileManager(flags);
|
||||
case 'token':
|
||||
return token(flags);
|
||||
default:
|
||||
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
||||
return 2;
|
||||
@@ -256,6 +261,57 @@ async function refresh(flags: Flags): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
|
||||
* cookie, paste it here, close the window. The server checks the token with
|
||||
* Schulcloud before swapping it in, so a bad paste changes nothing.
|
||||
*/
|
||||
async function token(flags: Flags): Promise<number> {
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const sub = flags._[0];
|
||||
|
||||
if (sub === undefined || sub === 'status') {
|
||||
process.stdout.write(`${describeToken(await api.token())}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (sub !== 'set') {
|
||||
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const pasted = process.stdin.isTTY
|
||||
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
|
||||
: await readPiped();
|
||||
if (!pasted.trim()) {
|
||||
process.stderr.write('No token given.\n');
|
||||
return 2;
|
||||
}
|
||||
process.stderr.write('Checking it with Schulcloud…\n');
|
||||
const result = await api.replaceToken(pasted);
|
||||
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
|
||||
if (result.changed && !result.persisted) {
|
||||
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
|
||||
}
|
||||
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function describeToken(info: TokenInfo): string {
|
||||
const expiry = info.expiresAt
|
||||
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
|
||||
: 'expiry unknown';
|
||||
const keepalive = info.keepalive;
|
||||
const session = !keepalive
|
||||
? 'keepalive off'
|
||||
: keepalive.running
|
||||
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
|
||||
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
|
||||
const source =
|
||||
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
|
||||
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
|
||||
return `${expiry}; ${session}; ${source}${warning}`;
|
||||
}
|
||||
|
||||
function describe(event: SyncEvent, dryRun: boolean): string {
|
||||
switch (event.type) {
|
||||
case 'download':
|
||||
|
||||
@@ -20,7 +20,25 @@ async function main(): Promise<void> {
|
||||
console.log(message),
|
||||
)
|
||||
: undefined;
|
||||
services.keepalive = keepalive;
|
||||
keepalive?.start();
|
||||
// A replaced token is a new session to hold, and very likely the fix for a
|
||||
// keepalive that stopped on a 401.
|
||||
services.session.onReplaced(() => keepalive?.restart());
|
||||
|
||||
// The token's 30 days end on a date nothing else announces, and the only fix
|
||||
// needs a person at a browser — so warn a week ahead, twice a day.
|
||||
const warnIfExpiring = () => {
|
||||
const { daysLeft } = services.session.status();
|
||||
if (daysLeft === undefined || daysLeft > 7) return;
|
||||
console.log(
|
||||
daysLeft < 0
|
||||
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
|
||||
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
|
||||
);
|
||||
};
|
||||
warnIfExpiring();
|
||||
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
|
||||
|
||||
// Periodic re-crawl so the index does not drift. Each run only downloads
|
||||
// files it has never seen, so a steady state costs a few hundred cheap GETs.
|
||||
@@ -49,9 +67,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const server = app.listen(config.port, config.bindHost, () => {
|
||||
const token = services.session.status();
|
||||
console.log(
|
||||
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
|
||||
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
|
||||
);
|
||||
|
||||
@@ -59,6 +59,15 @@ export interface FsWalk {
|
||||
failures?: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
|
||||
export interface TokenInfo {
|
||||
expiresAt?: string;
|
||||
daysLeft?: number;
|
||||
source: string;
|
||||
persistent: boolean;
|
||||
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -98,6 +107,20 @@ export class ApiClient {
|
||||
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async token(): Promise<TokenInfo> {
|
||||
return (await (await this.request('/api/token')).json()) as TokenInfo;
|
||||
}
|
||||
|
||||
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
|
||||
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
|
||||
const response = await this.request('/api/token', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jwt }),
|
||||
});
|
||||
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
|
||||
}
|
||||
|
||||
async manifest(since?: string): Promise<Manifest> {
|
||||
const query = since ? `?since=${encodeURIComponent(since)}` : '';
|
||||
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
|
||||
|
||||
53
src/cli/prompt.ts
Normal file
53
src/cli/prompt.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reading a secret the user pastes.
|
||||
*
|
||||
* The Schulcloud token grants read access to the whole account, so it is never
|
||||
* a command-line argument (shell history) and never echoed (scrollback).
|
||||
*/
|
||||
|
||||
/** A line typed or pasted at the terminal, not echoed. */
|
||||
export function readHidden(prompt: string): Promise<string> {
|
||||
const input = process.stdin;
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = '';
|
||||
const finish = () => {
|
||||
input.off('data', onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
process.stderr.write('\n');
|
||||
};
|
||||
const onData = (chunk: string) => {
|
||||
for (const char of chunk) {
|
||||
if (char === '\r' || char === '\n') {
|
||||
finish();
|
||||
// A terminal with bracketed paste on wraps a paste in markers.
|
||||
resolve(value.replace(/\[20[01]~/g, ''));
|
||||
return;
|
||||
}
|
||||
if (char === '' || char === '') {
|
||||
finish();
|
||||
reject(new Error('Cancelled.'));
|
||||
return;
|
||||
}
|
||||
if (char === '' || char === '\b') {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
value += char;
|
||||
}
|
||||
};
|
||||
process.stderr.write(prompt);
|
||||
input.setRawMode(true);
|
||||
input.setEncoding('utf8');
|
||||
input.resume();
|
||||
input.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
|
||||
export async function readPiped(): Promise<string> {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
for await (const chunk of process.stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Runtime configuration, read once from the environment.
|
||||
* Runtime configuration, read once from the environment — except `jwt`, which
|
||||
* can be replaced while the server runs.
|
||||
*
|
||||
* The two Schulcloud values are named after the browser artefacts they come
|
||||
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
|
||||
@@ -11,10 +12,16 @@ import { resolve } from 'node:path';
|
||||
export interface Config {
|
||||
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
||||
baseUrl: string;
|
||||
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
|
||||
/**
|
||||
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
|
||||
* Replaced at runtime by core/session-token.ts, so read it at the moment of
|
||||
* use and never keep a copy.
|
||||
*/
|
||||
jwt: string;
|
||||
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||
authToken: string | undefined;
|
||||
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
|
||||
stateDir: string | undefined;
|
||||
port: number;
|
||||
bindHost: string;
|
||||
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
|
||||
@@ -82,6 +89,7 @@ export function loadConfig(): Config {
|
||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||
jwt: required('TSC_JWT_COOKIE'),
|
||||
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
|
||||
port: int('PORT', 8080),
|
||||
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
||||
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||
|
||||
@@ -133,12 +133,15 @@ export class SchulcloudClient {
|
||||
url: URL,
|
||||
accept: string,
|
||||
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
|
||||
options: { idleTimeout?: boolean } = {},
|
||||
options: { idleTimeout?: boolean; token?: string } = {},
|
||||
): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
const headers: Record<string, string> = { Accept: accept };
|
||||
if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`;
|
||||
if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`;
|
||||
// Read at the moment of use, never earlier: the token can be replaced
|
||||
// while the server runs (core/session-token.ts).
|
||||
const jwt = options.token ?? this.config.jwt;
|
||||
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
|
||||
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) await delay(backoffMs(attempt));
|
||||
@@ -253,6 +256,15 @@ export class SchulcloudClient {
|
||||
return this.getJson<MeResponse>('/api/v3/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* `/me` as another token sees it, leaving the token in use untouched — how a
|
||||
* replacement is checked before it is swapped in.
|
||||
*/
|
||||
async meAs(token: string): Promise<MeResponse> {
|
||||
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
|
||||
return (await response.json()) as MeResponse;
|
||||
}
|
||||
|
||||
// --- session ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
|
||||
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
||||
* not to ping harder.
|
||||
*/
|
||||
export interface KeepaliveState {
|
||||
running: boolean;
|
||||
/** Seconds of session the instance reported at the last successful extension. */
|
||||
budgetSeconds: number | undefined;
|
||||
lastExtendedAt: string | undefined;
|
||||
/** Set once the instance refused the token; cleared by a restart. */
|
||||
rejectedAt: string | undefined;
|
||||
}
|
||||
|
||||
export class SessionKeepalive {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private stopped = false;
|
||||
/**
|
||||
* Bumped by every start and stop. A ping still in flight when the token is
|
||||
* replaced was sent with the old token, and its 401 must not stop the
|
||||
* keepalive that is already running with the new one.
|
||||
*/
|
||||
private generation = 0;
|
||||
private budgetSeconds: number | undefined;
|
||||
private lastExtendedAt: Date | undefined;
|
||||
private rejectedAt: Date | undefined;
|
||||
private readonly client: SchulcloudClient;
|
||||
private readonly intervalMs: number;
|
||||
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
||||
@@ -47,27 +65,52 @@ export class SessionKeepalive {
|
||||
|
||||
/** Pings once now (validating the token at startup), then on the interval. */
|
||||
start(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.stopped = false;
|
||||
void this.tick();
|
||||
this.rejectedAt = undefined;
|
||||
void this.tick(++this.generation);
|
||||
}
|
||||
|
||||
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
|
||||
restart(): void {
|
||||
this.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.generation++;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (this.stopped) return;
|
||||
this.timer = setTimeout(() => void this.tick(), delayMs);
|
||||
state(): KeepaliveState {
|
||||
return {
|
||||
running: !this.stopped,
|
||||
budgetSeconds: this.budgetSeconds,
|
||||
lastExtendedAt: this.lastExtendedAt?.toISOString(),
|
||||
rejectedAt: this.rejectedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private current(generation: number): boolean {
|
||||
return !this.stopped && generation === this.generation;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number, generation: number): void {
|
||||
if (!this.current(generation)) return;
|
||||
this.timer = setTimeout(() => void this.tick(generation), delayMs);
|
||||
// Never hold the process open just for a keepalive.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
private async tick(generation: number): Promise<void> {
|
||||
if (!this.current(generation)) return;
|
||||
try {
|
||||
const { expiresInSeconds } = await this.client.extendSession();
|
||||
if (!this.current(generation)) return;
|
||||
this.budgetSeconds = expiresInSeconds;
|
||||
this.lastExtendedAt = new Date();
|
||||
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
||||
// extension is not taking effect — worth seeing in the log, because it
|
||||
// is the early warning that the session is about to be lost.
|
||||
@@ -75,8 +118,9 @@ export class SessionKeepalive {
|
||||
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
||||
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
||||
);
|
||||
this.schedule(this.intervalMs);
|
||||
this.schedule(this.intervalMs, generation);
|
||||
} catch (error) {
|
||||
if (!this.current(generation)) return;
|
||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
|
||||
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
||||
@@ -86,16 +130,18 @@ export class SessionKeepalive {
|
||||
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
|
||||
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
|
||||
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
|
||||
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
|
||||
'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
|
||||
'the keepalive resumes by itself. Keepalive stopped.',
|
||||
);
|
||||
this.stop();
|
||||
this.rejectedAt = new Date();
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
||||
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
||||
);
|
||||
this.schedule(this.retryMs);
|
||||
this.schedule(this.retryMs, generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
222
src/core/session-token.ts
Normal file
222
src/core/session-token.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Config } from '../config.ts';
|
||||
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
|
||||
|
||||
/**
|
||||
* The Schulcloud session token, replaceable while the server runs.
|
||||
*
|
||||
* A token lives 30 days at most, and a new one can only come from a browser:
|
||||
* the login is federated single sign-on, so this server cannot mint one
|
||||
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
|
||||
* it against the instance, swap it into the config every request reads it
|
||||
* from, and keep it in a state file, so a later restart does not fall back to
|
||||
* the older token still sitting in `.env`.
|
||||
*
|
||||
* The token is a credential with read access to the whole account. It goes
|
||||
* into the state file and nowhere else: not into logs, errors or responses.
|
||||
*/
|
||||
|
||||
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
|
||||
export interface TokenClaims {
|
||||
userId?: string;
|
||||
/** Seconds since the epoch. */
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
|
||||
|
||||
export interface TokenStatus {
|
||||
expiresAt: string | undefined;
|
||||
/** Whole days until expiry; negative once expired. */
|
||||
daysLeft: number | undefined;
|
||||
source: TokenSource;
|
||||
/** Whether a replacement survives a restart, which needs STATE_DIR. */
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
|
||||
|
||||
/** A replacement that was refused, with a message saying what to do instead. */
|
||||
export class TokenRejected extends Error {
|
||||
readonly problem: TokenProblem;
|
||||
|
||||
constructor(problem: TokenProblem, message: string) {
|
||||
super(message);
|
||||
this.name = 'TokenRejected';
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
|
||||
const STATE_FILE = 'schulcloud-jwt';
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* The token inside whatever was pasted.
|
||||
*
|
||||
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
|
||||
* and a trailing newline all happen on the way from a browser to a terminal,
|
||||
* and none of them is worth a refused replacement.
|
||||
*/
|
||||
export function normalizeToken(input: string): string {
|
||||
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
|
||||
return unquote(
|
||||
unquote(input)
|
||||
.replace(/^jwt\s*=\s*/i, '')
|
||||
.replace(/;.*$/s, ''),
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeClaims(token: string): TokenClaims | undefined {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
|
||||
try {
|
||||
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
const { userId, exp } = payload as Record<string, unknown>;
|
||||
return {
|
||||
userId: typeof userId === 'string' ? userId : undefined,
|
||||
exp: typeof exp === 'number' ? exp : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionToken {
|
||||
private readonly config: Config;
|
||||
private readonly client: Pick<SchulcloudClient, 'meAs'>;
|
||||
private readonly stateFile: string | undefined;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private source: TokenSource = 'environment';
|
||||
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the token to start with: the saved one when it is the newer of the
|
||||
* two, since that is what a runtime replacement leaves behind — but never one
|
||||
* for a different account than `TSC_JWT_COOKIE`, because changing that
|
||||
* variable is how accounts are switched.
|
||||
*/
|
||||
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
|
||||
if (!this.stateFile) return;
|
||||
let saved: string;
|
||||
try {
|
||||
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
|
||||
return;
|
||||
}
|
||||
if (!saved || saved === this.config.jwt) return;
|
||||
|
||||
const fromState = decodeClaims(saved);
|
||||
const fromEnvironment = decodeClaims(this.config.jwt);
|
||||
if (!fromState) {
|
||||
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
|
||||
return;
|
||||
}
|
||||
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
|
||||
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
|
||||
return;
|
||||
}
|
||||
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
|
||||
this.config.jwt = saved;
|
||||
this.source = 'state file';
|
||||
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
|
||||
}
|
||||
}
|
||||
|
||||
status(): TokenStatus {
|
||||
const exp = decodeClaims(this.config.jwt)?.exp;
|
||||
return {
|
||||
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
|
||||
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
|
||||
source: this.source,
|
||||
persistent: this.stateFile !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Called after every successful replacement — the keepalive restarts on it. */
|
||||
onReplaced(listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||
const run = this.queue.then(() => this.swap(input));
|
||||
this.queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||
const token = normalizeToken(input);
|
||||
const claims = decodeClaims(token);
|
||||
if (!claims) {
|
||||
throw new TokenRejected(
|
||||
'malformed',
|
||||
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
|
||||
'Copy the Value column of the cookie named "jwt".',
|
||||
);
|
||||
}
|
||||
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
|
||||
throw new TokenRejected(
|
||||
'expired',
|
||||
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
|
||||
);
|
||||
}
|
||||
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
userId = (await this.client.meAs(token)).user.id;
|
||||
} catch (error) {
|
||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||
throw new TokenRejected(
|
||||
'rejected',
|
||||
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
|
||||
'window and copy the cookie — then close the window, because left open it logs the token out ' +
|
||||
'about two hours after login.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const current = decodeClaims(this.config.jwt)?.userId;
|
||||
if (current && userId !== current) {
|
||||
throw new TokenRejected(
|
||||
'other_account',
|
||||
'That token belongs to a different Schulcloud account than the one this server reads. ' +
|
||||
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
|
||||
);
|
||||
}
|
||||
|
||||
this.config.jwt = token;
|
||||
this.source = 'replaced at runtime';
|
||||
const persisted = await this.persist(token);
|
||||
for (const listener of this.listeners) listener();
|
||||
return { changed: true, persisted, status: this.status() };
|
||||
}
|
||||
|
||||
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
|
||||
private async persist(token: string): Promise<boolean> {
|
||||
if (!this.stateFile) return false;
|
||||
try {
|
||||
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
|
||||
await rename(temporary, this.stateFile);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
|
||||
'a restart will fall back to TSC_JWT_COOKIE',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WalkEntry,
|
||||
} from '../core/legacy-files.ts';
|
||||
import { resolveWithin } from '../core/paths.ts';
|
||||
import { TokenRejected } from '../core/session-token.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,8 @@ import type { Services } from '../services.ts';
|
||||
* from the mirror does neither, which matters for the video files.
|
||||
*
|
||||
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
|
||||
* index and mirror, and every upstream call it triggers is a GET.
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
export function createApiRouter(services: Services): Router {
|
||||
const router = express.Router();
|
||||
@@ -229,9 +231,57 @@ export function createApiRouter(services: Services): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// --- the Schulcloud session token -------------------------------------------
|
||||
//
|
||||
// A write, but to this server's own state: the token it reads Schulcloud with.
|
||||
// The only upstream call is the GET /me a replacement must pass first. Works
|
||||
// without an index, since a server without one still needs a token.
|
||||
|
||||
router.get('/token', (_req: Request, res: Response) => {
|
||||
res.json(tokenStatus(services));
|
||||
});
|
||||
|
||||
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
|
||||
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
|
||||
if (typeof jwt !== 'string' || !jwt.trim()) {
|
||||
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
|
||||
}
|
||||
try {
|
||||
const { changed, persisted } = await services.session.replace(jwt);
|
||||
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
|
||||
return res.json({ changed, persisted, ...tokenStatus(services) });
|
||||
} catch (error) {
|
||||
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
|
||||
// Anything else is the instance failing to answer the check. The error
|
||||
// cannot contain the token — SchulcloudApiError carries only a path — but
|
||||
// the response still says no more than that.
|
||||
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
|
||||
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
|
||||
return res.status(502).json({
|
||||
error: 'check_failed',
|
||||
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A body that is not JSON would otherwise reach Express's default handler,
|
||||
// which logs it — and here the body is a credential.
|
||||
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
|
||||
const type = (error as { type?: string } | undefined)?.type;
|
||||
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
|
||||
res.status(400).json({ error: 'bad_request' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function tokenStatus(services: Services) {
|
||||
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
|
||||
}
|
||||
|
||||
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
|
||||
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
|
||||
async function proxyFileManager(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createServer } from '../mcp/server.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
import { createApiRouter } from './api.ts';
|
||||
import { bearerAuth } from './auth.ts';
|
||||
import { tokenPage, tokenScript } from './token-page.ts';
|
||||
|
||||
/**
|
||||
* Streamable-HTTP front end, for use as a remote MCP connector.
|
||||
@@ -71,6 +72,10 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
|
||||
if (services) {
|
||||
app.use(API_PATH, createApiRouter(services));
|
||||
// The page to paste a fresh Schulcloud token into. It holds no secret: what
|
||||
// it sends goes to /api/token, behind the bearer check above.
|
||||
app.get('/token', tokenPage);
|
||||
app.get('/token.js', tokenScript);
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
142
src/http/token-page.ts
Normal file
142
src/http/token-page.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
|
||||
* is not at hand. `schulcloud token set` does the same from the CLI.
|
||||
*
|
||||
* The page carries no secret and needs no login of its own. It sends what is
|
||||
* typed into it to `PUT /api/token` with the server access token as a bearer,
|
||||
* so it is exactly as protected as the API — and the server checks the pasted
|
||||
* token against Schulcloud before using it.
|
||||
*
|
||||
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
|
||||
* one-click bookmarklet is impossible; copying it out of DevTools is the step
|
||||
* that remains.
|
||||
*/
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
// The page's script is a separate file only because this policy forbids
|
||||
// inline script; nothing it loads comes from anywhere else.
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
|
||||
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Cache-Control': 'no-store',
|
||||
};
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Schulcloud token</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 2rem 1rem; }
|
||||
main { max-width: 34rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
ol { padding-left: 1.2rem; line-height: 1.5; }
|
||||
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
#result { margin-top: 1rem; min-height: 1.5em; }
|
||||
.ok { color: #1a7f37; }
|
||||
.error { color: #cf222e; }
|
||||
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Replace the Schulcloud token</h1>
|
||||
<ol>
|
||||
<li>Open a private window and log in to Schulcloud.</li>
|
||||
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
|
||||
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
|
||||
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
|
||||
</ol>
|
||||
<form id="form">
|
||||
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
|
||||
<input id="access" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="jwt">jwt cookie</label>
|
||||
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||
<div class="actions">
|
||||
<button type="submit">Replace</button>
|
||||
<button type="button" id="check">Check current token</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="result" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script src="token.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const SCRIPT = `'use strict';
|
||||
const form = document.getElementById('form');
|
||||
const access = document.getElementById('access');
|
||||
const jwt = document.getElementById('jwt');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
function show(text, ok) {
|
||||
result.textContent = text;
|
||||
result.className = ok ? 'ok' : 'error';
|
||||
}
|
||||
|
||||
function describe(status) {
|
||||
const expiry = status.expiresAt
|
||||
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
|
||||
: 'expiry unknown';
|
||||
const keepalive = status.keepalive;
|
||||
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
|
||||
return [expiry, session].filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
async function call(method, body) {
|
||||
const headers = { authorization: 'Bearer ' + access.value.trim() };
|
||||
if (body) headers['content-type'] = 'application/json';
|
||||
const response = await fetch('api/token', {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) throw new Error('The server access token is wrong.');
|
||||
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
|
||||
show('Checking it with Schulcloud…', true);
|
||||
try {
|
||||
const data = await call('PUT', { jwt: jwt.value });
|
||||
jwt.value = '';
|
||||
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
|
||||
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
|
||||
} catch (error) {
|
||||
show(error.message, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('check').addEventListener('click', async () => {
|
||||
try {
|
||||
show('Current token ' + describe(await call('GET')) + '.', true);
|
||||
} catch (error) {
|
||||
show(error.message, false);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
export function tokenPage(_req: Request, res: Response): void {
|
||||
res.set(SECURITY_HEADERS).type('html').send(PAGE);
|
||||
}
|
||||
|
||||
export function tokenScript(_req: Request, res: Response): void {
|
||||
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { decodeClaims } from '../../core/session-token.ts';
|
||||
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
|
||||
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
|
||||
import { text, toToolError } from './result.ts';
|
||||
@@ -31,7 +32,10 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
|
||||
`- Instance: ${context.config.baseUrl}`,
|
||||
`- Permissions: ${me.permissions.length}`,
|
||||
].join('\n'),
|
||||
tokenExpiryLine(context.config.jwt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -184,6 +188,18 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the server's Schulcloud token runs out. Only a person can renew it, so
|
||||
* the week before is worth saying out loud wherever the account is shown.
|
||||
*/
|
||||
function tokenExpiryLine(jwt: string): string | undefined {
|
||||
const exp = decodeClaims(jwt)?.exp;
|
||||
if (exp === undefined) return undefined;
|
||||
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
|
||||
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
|
||||
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
|
||||
}
|
||||
|
||||
function isCurrentlyRunning(course: CourseMetadata): boolean {
|
||||
const now = Date.now();
|
||||
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
|
||||
|
||||
@@ -56,9 +56,9 @@ function describeFailure(error: unknown, action: string): string {
|
||||
if (error.isAuthFailure) {
|
||||
return (
|
||||
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
|
||||
`The JWT in TSC_JWT_COOKIE has expired or been revoked. Copy a fresh one from ` +
|
||||
`the browser (DevTools → Application → Cookies → the "jwt" cookie) into the server's ` +
|
||||
`environment and restart it. See docs/AUTH.md.`
|
||||
`The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
|
||||
`browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
|
||||
`with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
|
||||
);
|
||||
}
|
||||
if (error.status === 403) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from './config.ts';
|
||||
import { SchulcloudClient } from './core/client.ts';
|
||||
import type { SessionKeepalive } from './core/keepalive.ts';
|
||||
import { FileManager } from './core/legacy-files.ts';
|
||||
import { SessionToken } from './core/session-token.ts';
|
||||
import { Indexer } from './indexer/indexer.ts';
|
||||
import { Store } from './store/store.ts';
|
||||
|
||||
@@ -23,10 +25,22 @@ export interface Services {
|
||||
files: FileManager;
|
||||
store: Store | undefined;
|
||||
indexer: Indexer | undefined;
|
||||
/** The Schulcloud token, which `/api/token` can replace without a restart. */
|
||||
session: SessionToken;
|
||||
/**
|
||||
* Set by the entry point that runs one, for status reports. Created there
|
||||
* rather than here because each entry point logs to a different stream.
|
||||
*/
|
||||
keepalive?: SessionKeepalive;
|
||||
}
|
||||
|
||||
export async function createServices(config: Config): Promise<Services> {
|
||||
const client = new SchulcloudClient(config);
|
||||
// Before anything else reads config.jwt: a token replaced at runtime and
|
||||
// saved may be newer than the one in the environment.
|
||||
const session = new SessionToken(config, client, config.stateDir);
|
||||
await session.load();
|
||||
|
||||
const files = new FileManager(client);
|
||||
const store = await Store.open(config.databaseUrl);
|
||||
const indexer = store ? new Indexer(client, store, config) : undefined;
|
||||
@@ -37,7 +51,7 @@ export async function createServices(config: Config): Promise<Services> {
|
||||
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
|
||||
);
|
||||
}
|
||||
return { config, client, files, store, indexer };
|
||||
return { config, client, files, store, indexer, session };
|
||||
}
|
||||
|
||||
export async function closeServices(services: Services): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user