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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user