diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b9e306ea..0ace86811 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1853,6 +1853,12 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. +In standalone development and preview servers, a missing Google tile-token +provider returns an uncached `204 No Content` for `GET /api/google/tiles-token` +(and HEAD) instead of a browser-visible 404. The fallback is registered after +real token providers and before the unknown-API handler, so existing Google +browser keys, provider-issued tokens, and unrelated API routes remain unchanged. + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffe5124ce..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 884c5b199..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,6 +38,7 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); + if (response.status === 204) return null; if (!response.ok) { await response.body?.cancel(); return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index d8a1e84ed..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/tooling/googleTokenUnavailable.test.mjs b/src/tooling/googleTokenUnavailable.test.mjs new file mode 100644 index 000000000..e0ee19232 --- /dev/null +++ b/src/tooling/googleTokenUnavailable.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js'; + +async function withServer({ beforeFallback } = {}, check) { + const middlewares = []; + const app = { + middlewares: { + use: (handler) => middlewares.push(handler), + }, + }; + if (beforeFallback) app.middlewares.use(beforeFallback); + googleTokenUnavailablePlugin().configureServer(app); + app.middlewares.use((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + + const server = createServer((req, res) => { + let index = 0; + const next = () => middlewares[index++]?.(req, res, next); + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const url = 'http://127.0.0.1:' + address.port; + try { + await check(url); + } finally { + server.close(); + await once(server, 'close'); + } +} + +test('keyless GET and HEAD return uncached 204 instead of 404', async () => { + await withServer({}, async (url) => { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url + '/api/google/tiles-token?probe=1', { + method, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await response.text(), ''); + } + }); +}); + +test('the existing client treats unavailable tokens as null', async () => { + await withServer({}, async (url) => { + const source = createGoogleTokenSource({ + fetchImpl: (path, options) => fetch(new URL(path, url), options), + }); + assert.equal(await source.token(), null); + }); +}); + +test('unrelated API paths and unsupported methods remain 404', async () => { + await withServer({}, async (url) => { + for (const [path, method] of [ + ['/api/google/tiles-token/extra', 'GET'], + ['/api/not-a-token', 'GET'], + ['/api/google/tiles-token', 'POST'], + ]) { + const response = await fetch(new URL(path, url), { method }); + assert.equal(response.status, 404); + } + }); +}); + +test('a real token provider registered first still wins', async () => { + await withServer( + { + beforeFallback: (req, res, next) => { + if (req.url !== '/api/google/tiles-token') return next(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + accessToken: 'server-token', + expiresAt: 10000000000000, + }), + ); + }, + }, + async (url) => { + const response = await fetch(url + '/api/google/tiles-token'); + assert.equal(response.status, 200); + assert.equal((await response.json()).accessToken, 'server-token'); + }, + ); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 2a4988e30..a9e8c5398 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -117,7 +117,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -5) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -131,9 +131,10 @@ test('root config retains existing named exports and standalone provider order', standaloneProviders.filter((name) => name !== 'gemini-live-proxy'), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); - // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-5).name, 'local-mcp'); + assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + // Token fallback follows real providers and local MCP before the API 404. + assert.equal(config.plugins.at(-6).name, 'local-mcp'); + assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');