diff --git a/src/backend/controllers/system/SystemController.js b/src/backend/controllers/system/SystemController.js
index 1c30739fe..327e668d9 100644
--- a/src/backend/controllers/system/SystemController.js
+++ b/src/backend/controllers/system/SystemController.js
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
- * Puter is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
+ * Puter is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU Affero General Public License as published by the Free
+ * Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
*
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ * details.
*
* You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
+ * along with this program. If not, see
+ * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { HttpError } from '../../core/http/HttpError.js';
@@ -75,6 +76,11 @@ export class SystemController extends PuterController {
process.env.npm_package_version ??
'unknown';
const parts = String(version).split('.');
+ // Deploy-constant, and callers poll it. Cache per-client only:
+ // a shared cache could pin one region's `location` for everyone,
+ // and the short window still bounds how long a client can miss a
+ // new deploy.
+ res.setHeader('Cache-Control', 'private, max-age=60');
res.json({
version,
major: parts[0] ? Number(parts[0]) : null,
diff --git a/src/backend/controllers/system/SystemController.test.ts b/src/backend/controllers/system/SystemController.test.ts
index 5690fc6e3..2936213c1 100644
--- a/src/backend/controllers/system/SystemController.test.ts
+++ b/src/backend/controllers/system/SystemController.test.ts
@@ -75,6 +75,7 @@ const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
interface CapturedResponse {
statusCode: number;
body: unknown;
+ headers: Record;
}
const makeReq = (init: {
@@ -91,7 +92,11 @@ const makeReq = (init: {
};
const makeRes = () => {
- const captured: CapturedResponse = { statusCode: 200, body: undefined };
+ const captured: CapturedResponse = {
+ statusCode: 200,
+ body: undefined,
+ headers: {},
+ };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
@@ -105,7 +110,10 @@ const makeRes = () => {
captured.statusCode = code;
return res;
}),
- setHeader: vi.fn(() => res),
+ setHeader: vi.fn((name: string, value: unknown) => {
+ captured.headers[name] = value;
+ return res;
+ }),
};
return { res: res as unknown as Response, captured };
};
@@ -342,6 +350,12 @@ describe('SystemController GET /version', () => {
expect(body.environment).toBe('dev');
expect(typeof body.deploy_timestamp).toBe('number');
});
+
+ it('is cacheable per-client but never by a shared cache', async () => {
+ const { res, captured } = makeRes();
+ await callRoute('get', '/version', makeReq({}), res);
+ expect(captured.headers['Cache-Control']).toBe('private, max-age=60');
+ });
});
// ── /contactUs ──────────────────────────────────────────────────────
diff --git a/src/backend/core/http/middleware/authProbe.test.ts b/src/backend/core/http/middleware/authProbe.test.ts
index 1a78d4acf..50dc627c8 100644
--- a/src/backend/core/http/middleware/authProbe.test.ts
+++ b/src/backend/core/http/middleware/authProbe.test.ts
@@ -699,6 +699,71 @@ describe('createAuthProbe — reauth signal', () => {
}
});
+ it('collapses repeat reauth lines for the same auth_id to one', async () => {
+ const stub = makeStubAuth();
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
+ try {
+ const probe = createAuthProbe({ authService: stub.service });
+ for (let i = 0; i < 5; i++) {
+ stub.setNextResult({
+ reauth: { reason: 'token_v1', auth_id: 'u-noisy' },
+ });
+ await runProbe(
+ probe,
+ makeReq({ headers: { authorization: 'Bearer tok' } }),
+ );
+ }
+ const reauthCalls = infoSpy.mock.calls.filter((args) =>
+ String(args[0]).startsWith('[auth-v2] reauth'),
+ );
+ expect(reauthCalls).toHaveLength(1);
+ } finally {
+ infoSpy.mockRestore();
+ }
+ });
+
+ it('still logs separately for a different auth_id', async () => {
+ const stub = makeStubAuth();
+ const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
+ try {
+ const probe = createAuthProbe({ authService: stub.service });
+ for (const authId of ['u-a', 'u-b']) {
+ stub.setNextResult({
+ reauth: { reason: 'token_v1', auth_id: authId },
+ });
+ await runProbe(
+ probe,
+ makeReq({ headers: { authorization: 'Bearer tok' } }),
+ );
+ }
+ const reauthCalls = infoSpy.mock.calls.filter((args) =>
+ String(args[0]).startsWith('[auth-v2] reauth'),
+ );
+ expect(reauthCalls).toHaveLength(2);
+ } finally {
+ infoSpy.mockRestore();
+ }
+ });
+
+ it('does not sign a reauth token until something reads it', async () => {
+ const stub = makeStubAuth();
+ stub.setNextResult({
+ reauth: { reason: 'token_v1', auth_id: 'u-lazy' },
+ });
+ const signSpy = vi.spyOn(stub.service, 'signReauthToken');
+ const { req } = await runProbe(
+ createAuthProbe({ authService: stub.service }),
+ makeReq({ headers: { authorization: 'Bearer tok' } }),
+ );
+
+ expect(signSpy).not.toHaveBeenCalled();
+ expect(req.requiresReauth?.reauth_token).toBeTruthy();
+ expect(signSpy).toHaveBeenCalledTimes(1);
+ // Memoized — a second read must not re-sign.
+ void req.requiresReauth?.reauth_token;
+ expect(signSpy).toHaveBeenCalledTimes(1);
+ });
+
it('does not emit a reauth log line on a healthy v2 verify', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts
index 768917439..83bd7a118 100644
--- a/src/backend/core/http/middleware/authProbe.ts
+++ b/src/backend/core/http/middleware/authProbe.ts
@@ -57,8 +57,35 @@ interface AuthProbeOptions {
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
+// Reauth is a property of a session, not an event: a client still holding a
+// legacy token repeats the identical line on every request it makes, which
+// buries every other log line without adding information. Keep the forensic
+// signal but emit it at most once per auth_id per window.
+const REAUTH_LOG_WINDOW_MS = 10 * 60 * 1000;
+// Bounds the map so a flood of distinct ids can't grow it without limit.
+const REAUTH_LOG_MAX_KEYS = 1024;
+
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
const { authService, cookieName } = opts;
+
+ const reauthLoggedAt = new Map();
+ const shouldLogReauth = (key: string): boolean => {
+ const now = Date.now();
+ const last = reauthLoggedAt.get(key);
+ if (last !== undefined && now - last < REAUTH_LOG_WINDOW_MS) {
+ return false;
+ }
+ if (reauthLoggedAt.size >= REAUTH_LOG_MAX_KEYS) {
+ // Map iterates in insertion order and every log re-inserts, so
+ // the first key is the least recently logged.
+ const oldest = reauthLoggedAt.keys().next().value;
+ if (oldest !== undefined) reauthLoggedAt.delete(oldest);
+ }
+ reauthLoggedAt.delete(key);
+ reauthLoggedAt.set(key, now);
+ return true;
+ };
+
return async (req, _res, next): Promise => {
// If something upstream already attached an actor, respect it.
if (req.actor) {
@@ -83,21 +110,42 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
});
if (result.reauth) {
+ const { reason, auth_id } = result.reauth;
// Bind a short-lived JWT proving the rejected session
// identified this auth_id. The GUI echoes this back on
// /login or /signup; the raw auth_id is informational
// only and is not accepted as authoritative on its own.
- const reauth_token = result.reauth.auth_id
- ? authService.signReauthToken(result.reauth.auth_id)
- : undefined;
+ //
+ // Signed on read, not here: a legacy-but-still-valid token
+ // sets `reauth` on a request that then succeeds, and only
+ // the 401 path ever reads the token, so signing eagerly
+ // burns a JWT per request for a value nobody looks at.
+ let signed = false;
+ let signedToken: string | undefined;
req.requiresReauth = {
- reason: result.reauth.reason as ReauthReason,
- auth_id: result.reauth.auth_id,
- ...(reauth_token ? { reauth_token } : {}),
+ reason: reason as ReauthReason,
+ auth_id,
+ get reauth_token() {
+ if (!signed) {
+ signed = true;
+ try {
+ signedToken = auth_id
+ ? authService.signReauthToken(auth_id)
+ : undefined;
+ } catch {
+ // Losing the hint is survivable; the client
+ // still gets `reauth_required` and can log in.
+ signedToken = undefined;
+ }
+ }
+ return signedToken;
+ },
};
- console.info(
- `[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
- );
+ if (shouldLogReauth(`${reason}:${auth_id ?? '-'}`)) {
+ console.info(
+ `[auth-v2] reauth reason=${reason} auth_id=${auth_id ?? '-'}`,
+ );
+ }
}
if (result.blocked) {
diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js
index be493dc34..8f8537ae2 100644
--- a/src/gui/src/globals.js
+++ b/src/gui/src/globals.js
@@ -233,15 +233,26 @@ window.PANEL_WIDTH = 400;
// the transaction class
window.Transaction = class {
- constructor (name) {
+ constructor (name, attributes = {}) {
this.name = name;
this.id = uuidv4();
+ // Reported alongside the timing so a slow transaction can be
+ // attributed to a particular subject rather than only a name.
+ this.attributes = { ...attributes };
}
start () {
this.start_ts = Date.now();
}
+ /**
+ * Annotate the transaction with anything learned after it started (the
+ * resolved app name, which entry point triggered it, how it finished).
+ */
+ annotate (attributes) {
+ Object.assign(this.attributes, attributes);
+ }
+
getDuration () {
return Date.now() - this.start_ts;
}
diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js
index 23d7ec793..c12004750 100644
--- a/src/gui/src/helpers/launch_app.js
+++ b/src/gui/src/helpers/launch_app.js
@@ -52,8 +52,9 @@ const getLaunchResult = (launchOutcome) => {
return null;
};
-const endLaunchTransaction = (transaction) => {
+const endLaunchTransaction = (transaction, outcome) => {
if ( transaction ) {
+ if ( outcome ) transaction.annotate({ outcome });
transaction.end();
}
};
@@ -119,7 +120,17 @@ const launch_app = async (options) => {
// for it to be ready.
// Explorer is a special case, it's not an app per se, so it doesn't need a transaction.
if ( options?.name !== 'explorer' ) {
- transaction = new window.Transaction('app-is-ready');
+ // Attribute the timing: the same span covers a tile click on a warm
+ // dashboard and a cold landing on /app/, which are different
+ // enough that a combined percentile describes neither.
+ transaction = new window.Transaction('app-is-ready', {
+ 'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown',
+ 'launch.dashboard_mode': !! window.is_dashboard_mode,
+ 'launch.from_app_url':
+ typeof window.location?.pathname === 'string'
+ && window.location.pathname.startsWith('/app/'),
+ 'launch.has_app_obj': !! options?.app_obj,
+ });
transaction.start();
}
@@ -154,6 +165,7 @@ const launch_app = async (options) => {
// If no `options.name` is provided, use the app name from the app_info
options.name = options.name ?? app_info.name;
+ transaction?.annotate({ 'launch.app': options.name ?? 'unknown' });
const requestedAppName = options.privateLaunchRequestedAppName ?? options.name ?? app_info.name ?? null;
const privateAccessDecision = normalizePrivateAccessDecision(app_info.privateAccess);
@@ -191,7 +203,7 @@ const launch_app = async (options) => {
fallbackLaunchOutcome.launchResult = redirectedLaunchResult;
}
- endLaunchTransaction(transaction);
+ endLaunchTransaction(transaction, 'redirected-to-fallback');
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
}
@@ -217,7 +229,7 @@ const launch_app = async (options) => {
deniedPrivateAccess: true,
privateAccess: privateAccessDecision,
};
- endLaunchTransaction(transaction);
+ endLaunchTransaction(transaction, 'denied-private-access');
return { launchResult: deniedLaunchResult };
}
@@ -480,7 +492,7 @@ const launch_app = async (options) => {
privateAccess: privateAccessDecision ?? undefined,
authTokenAcquired: false,
};
- endLaunchTransaction(transaction);
+ endLaunchTransaction(transaction, 'token-unavailable');
return { launchResult: tokenFailureLaunchResult };
}
}
@@ -722,7 +734,7 @@ const launch_app = async (options) => {
};
// end the transaction
- endLaunchTransaction(transaction);
+ endLaunchTransaction(transaction, 'launched');
return process;
};
diff --git a/src/gui/src/index.js b/src/gui/src/index.js
index c6290f6b8..18f7b81bb 100644
--- a/src/gui/src/index.js
+++ b/src/gui/src/index.js
@@ -80,8 +80,16 @@ window.gui = async (options) => {
// await window.loadCSS('/dist/bundle.min.css');
}
- // Load Cloudflare Turnstile script
- await window.loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js', { defer: true });
+ // Load the captcha script alongside the GUI rather than ahead of it.
+ // Nothing during boot needs it — the challenge modal polls for the
+ // global and the signup form is opened long after — so awaiting it here
+ // only put a third-party round trip in front of every page load. Skipped
+ // entirely when no site key is configured, since every consumer gates on
+ // one.
+ if ( options.turnstileSiteKey ) {
+ window.loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js', { defer: true })
+ .catch(error => console.debug('Captcha script unavailable:', error));
+ }
// 🚀 Launch the GUI 🚀
window.initgui(options);