Merge upstream/main into pr/google-street-view

Conflicts with upstream's Street Level provider composition and share-state
codec, resolved:

- Street View's switch moves from its own share option (`g`) to upstream's
  provider-switch field (`r`, e.g. `0.r.google-1`); a link without it keeps
  Street View off. Docs and the browser gate follow.
- createApplicationStreetLevel keeps upstream's injectable provider list;
  its default, once the application supplies its Street Level source, is
  Mapillary plus Google Street View. Without that source there are none, as
  upstream intends.
- The coverage hint keeps upstream's empty-composition message and joins
  every switched-on provider's hint otherwise.
- CHANGELOG, README and SECURITY keep both sides' entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-12 11:32:05 +01:00
commit 76db4114ff
80 files changed
+3845 -201

No files matched your search

+16 -1
View File
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
},
},
},
catalog: createLayerCatalog(layers, metadata(layers)),
catalog: {
...createLayerCatalog(layers, metadata(layers)),
getSourceAvailability: (id) =>
id === 'one'
? {
available: false,
reason: 'One: data source not configured',
}
: undefined,
},
defer: (release) => releases.push(release),
onData: (manager) => {
observed = manager;
},
});
assert.equal(observed, dataManager);
assert.equal(await dataManager.setEnabled('one', true), false);
assert.equal(dataManager.layers.get('one').initialized, false);
assert.equal(
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
'One: data source not configured',
);
assert.deepEqual(attached, [['one', 'two']]);
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
+12 -38
View File
@@ -1,9 +1,9 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createApplicationFlights } from './layers/flights.js';
@@ -32,34 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
const SOURCE_METHODS = Object.freeze({
flights: ['getSnapshot'],
military: ['getSnapshot'],
vessels: ['getSnapshot'],
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
radio: ['getDirectory', 'recordClick'],
traffic: [
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
bikeshare: ['getStations'],
installations: ['getMappedSites', 'searchNearby'],
satellites: ['readGroup'],
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
mapillary: MAPILLARY_SOURCE_METHODS,
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
@@ -86,6 +58,7 @@ export function createApplicationCatalog({
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
}) {
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
@@ -93,12 +66,8 @@ export function createApplicationCatalog({
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
)
throw new TypeError(`Invalid catalog source: ${name}`);
}
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
@@ -108,7 +77,8 @@ export function createApplicationCatalog({
};
signal.addEventListener('abort', dispose, { once: true });
try {
militaryRegistry.configureSource(sources.military, { signal });
if (sourceComposition.isConfigured('military'))
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
@@ -154,6 +124,7 @@ export function createApplicationCatalog({
createApplicationStreetLevel({
surface,
sources: { mapillary: sources.mapillary },
providers: streetLevelProviders,
}),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
@@ -162,8 +133,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels,
installations,
createApplicationAwareness({
@@ -204,6 +175,9 @@ export function createApplicationCatalog({
);
return Object.freeze({
...catalog,
getSourceAvailability: sourceComposition.createAvailabilityLookup(
catalog.layers,
),
militaryRegistry,
surface,
weatherClock,
+52 -1
View File
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources();
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
assert.throws(
() =>
createApplicationCatalog({
sources: {},
sources: { flights: {} },
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
eventTarget: null,
});
}
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
const sources = fixtureSources([], []);
delete sources.mapillary;
const options = {
sources,
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
catalog.get('street-level'),
);
assert.match(
catalog.get('street-level').getUIState().coverage.hint,
/No street-level imagery providers/,
);
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const replacement = createApplicationCatalog({
...options,
streetLevelProviders: [provider],
});
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
assert.deepEqual(replacement.metadata, catalog.metadata);
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
assert.deepEqual(
createApplicationCatalog({
...options,
sources: fixtureSources([], []),
streetLevelProviders: [],
}).get('street-level').providerIds,
[],
);
assert.throws(
() =>
createApplicationCatalog({
...options,
sources: { ...sources, mapillary: {} },
}),
/Mapillary source/,
);
});
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
await dataManager.destroyAll();
+3 -9
View File
@@ -1,12 +1,6 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
/**
* Construct one Directions layer using the application scene owners.
*
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
}
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
thumbnails: createThumbnailLoader(),
});
}
+25 -6
View File
@@ -10,13 +10,32 @@ import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/** A new provider registers here; its chip, credit and share bit follow. */
export function createApplicationStreetLevel({ surface, sources }) {
/**
* The default providers, once the application supplies its Street Level
* source: Mapillary, and Google Street View, which needs only the browser
* Maps key (off until lit). Without that source there are none, and the
* layer reads as not configured.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
];
}
/**
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers: [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
],
providers,
services: {
sprites,
picking,
+45 -18
View File
@@ -1,24 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createApplicationStreetLevel } from './streetLevel.js';
import { createDefaultLayerState } from '../../data/layerState.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
test('each provider starts as its share-link option does, so a fresh page and a restored one agree', () => {
const layer = createApplicationStreetLevel({
surface: null,
sources: {
mapillary: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
},
test('explicit providers replace the default source and an empty list removes it', () => {
const sources = { mapillary: {} };
const provider = fakeStreetLevelProvider({
id: 'example',
pickPrefix: 'example:',
});
const options = createDefaultLayerState().options['street-level'];
// Every registered provider, listed on this map or not.
const started = layer.getParams();
for (const id of layer.providerIds)
assert.equal(started[id], options[id], id);
assert.equal(options.google, false, 'Street View is billed: off');
const layer = createApplicationStreetLevel({
sources,
providers: [provider],
});
assert.deepEqual(layer.providerIds, ['example']);
assert.deepEqual(
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
[],
);
assert.throws(
() => createApplicationStreetLevel({ sources }),
/Mapillary source/,
);
assert.throws(
() => createApplicationStreetLevel({ providers: null }),
/array/,
);
});
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1');
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+113
View File
@@ -0,0 +1,113 @@
const contract = (layers, methods) =>
Object.freeze({
layers: Object.freeze(layers),
methods: Object.freeze(methods),
});
/** Acquisition contracts and their consumers; records remain family-specific. */
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
flights: contract(['flights'], ['getSnapshot']),
military: contract(['military'], ['getSnapshot']),
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
cctv: contract(
['cctv'],
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
),
radio: contract(['radio'], ['getDirectory', 'recordClick']),
traffic: contract(
['traffic'],
[
'requestRoads',
'getStatus',
'fetchFlowForBounds',
'getFlowSessionStats',
'resetFlowTileCache',
],
),
bikeshare: contract(['bikeshare'], ['getStations']),
installations: contract(
['military-installations'],
['getMappedSites', 'searchNearby'],
),
satellites: contract(['satellites'], ['readGroup']),
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
alpr: contract(['alpr-cameras'], ['fetch']),
firms: contract(['local-firms'], ['getSnapshot']),
wind: contract(['wind'], ['getSnapshot']),
weather: contract(
['weather-radar', 'weather-satellite', 'weather-lightning'],
['getSnapshot'],
),
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
/** An omitted provider is unavailable, never an authoritative empty dataset. */
export class SourceUnavailableError extends Error {
constructor(name) {
super(`Data source not configured: ${name}`);
this.name = 'SourceUnavailableError';
this.code = 'unavailable';
this.unavailable = true;
this.retryable = false;
}
}
/** Validate supplied contracts and keep omitted sources inert during construction. */
export function resolveCatalogSources(supplied = {}) {
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
throw new TypeError('Catalog sources must be an object');
const sources = { ...supplied };
const missing = new Set();
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
const source = sources[name];
if (source == null) {
missing.add(name);
const unavailable = () => {
throw new SourceUnavailableError(name);
};
sources[name] = Object.freeze({
label: name,
...Object.fromEntries(methods.map((method) => [method, unavailable])),
});
} else if (methods.some((method) => typeof source[method] !== 'function')) {
throw new TypeError(`Invalid catalog source: ${name}`);
}
}
return {
sources,
isConfigured: (name) => !missing.has(name),
createAvailabilityLookup(layers) {
const byId = new Map(layers.map((layer) => [layer.id, layer]));
const availability = new Map();
for (const [name, { layers: ids }] of Object.entries(
CATALOG_SOURCE_CONTRACTS,
)) {
for (const id of ids) {
const layer = byId.get(id);
if (!layer) throw new Error(`Missing source consumer: ${id}`);
availability.set(
id,
Object.freeze({
available: !missing.has(name),
source: name,
reason: missing.has(name)
? `${layer.name || id}: data source not configured`
: null,
}),
);
}
}
return (id) =>
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCatalogSources,
CATALOG_SOURCE_CONTRACTS,
} from './sourceComposition.js';
import { createApplicationCatalog } from './constructCatalog.js';
import { createSurfaceServices } from './surfaceServices.js';
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { LayerLifecycle } from '../data/lifecycle.js';
function catalog(sources, t) {
const lifetime = new AbortController();
t.after(() => lifetime.abort());
return createApplicationCatalog({
sources,
signal: lifetime.signal,
surface: createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal: lifetime.signal,
eventTarget: null,
}),
});
}
test('every acquisition source can be omitted without losing catalog membership', (t) => {
const baseline = catalog(createStandaloneLayerSources(), t);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
const sources = createStandaloneLayerSources({ [name]: null });
const actual = catalog(sources, t);
assert.deepEqual(actual.metadata, baseline.metadata, name);
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
assert.equal(actual.getSourceAvailability(id).available, false, id);
}
}
});
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
for (const layer of actual.layers) manager.register(layer);
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
for (const id of ids) {
assert.equal(await manager.setEnabled(id, true), false, id);
assert.equal(manager.layers.get(id).initialized, false, id);
const row = manager.getAll().find((entry) => entry.id === id);
assert.equal(row.enabled, false);
assert.equal(row.stats.status, 'unavailable');
assert.match(row.stats.error, /data source not configured/);
}
}
});
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
const source = { getSnapshot: async () => [] };
assert.equal(
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
source,
);
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
assert.throws(
() => resolveCatalogSources({ [name]: {} }),
/Invalid catalog source/,
);
}
const missing = resolveCatalogSources({}).sources.earthquakes;
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
});
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
const source = { getSnapshot: async () => [] };
const sources = createStandaloneLayerSources({
flights: source,
military: null,
});
assert.equal(sources.flights, source);
assert.equal(sources.military, null);
assert.equal(typeof sources.cctv.getCatalog, 'function');
assert.throws(
() => createStandaloneLayerSources({ fligts: source }),
/Unknown source/,
);
});
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
const actual = catalog({}, t);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('street-level'));
assert.equal(await manager.setEnabled('street-level', true), false);
assert.equal(manager.layers.get('street-level').initialized, false);
const row = manager.getAll()[0];
assert.equal(row.stats.status, 'unavailable');
assert.equal(row.stats.error, 'No street-level imagery providers configured');
assert.equal(await manager.setEnabled('street-level', false), true);
const configured = catalog(createStandaloneLayerSources(), t);
assert.equal(
configured.get('street-level').getSourceAvailability().available,
true,
);
});
test('availability lookup accepts frozen layers and uses their display names', () => {
const composition = resolveCatalogSources({});
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
.flatMap(({ layers }) => layers)
.map((id) =>
Object.freeze({
id,
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
}),
);
const lookup = composition.createAvailabilityLookup(layers);
assert.equal(
lookup('fire-perimeters').reason,
'Fire perimeters: data source not configured',
);
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
assert.equal(
manager.getAll()[0].stats.error,
'Fire perimeters: data source not configured',
);
});
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
throw new Error('Unexpected fallback acquisition');
});
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
const registry = actual.militaryRegistry;
assert.equal(actual.getSourceAvailability('flights').available, true);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(registry.isMilitaryIcao('abc123'), false);
registry.registerMilitaryIcaos([' ABC123 ']);
await registry.refreshMilitaryRegistryIfStale();
assert.equal(registry.isMilitaryIcao('abc123'), true);
assert.equal(registry.isMilitaryIcao('def456'), false);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
);
manager.register(actual.get('military'));
assert.equal(await manager.setEnabled('military', true), false);
assert.equal(registry.isMilitaryLayerActive(), false);
assert.equal(fetches, 0);
});
+273
View File
@@ -0,0 +1,273 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
preferCodexOAuth,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
const nowMs = Date.UTC(2026, 8, 27);
const jwt = (payload) =>
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
const auth = (token, extras = {}) =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
...extras,
});
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
assert.equal(preferCodexOAuth({}), false);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
assert.throws(
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
/must be true or false/,
);
});
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
home: '/home/fixture',
}),
path.resolve('/tmp/custom-auth.json'),
);
assert.equal(
codexAuthPath({
environment: { CODEX_HOME: '/tmp/codex-home' },
home: '/home/fixture',
}),
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
);
assert.equal(
codexAuthPath({ environment: {}, home: '/home/fixture' }),
path.join('/home/fixture', '.codex', 'auth.json'),
);
});
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
const current = jwt({ exp: nowMs / 1000 + 3600 });
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(current),
}),
current,
);
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
}),
current,
'GEV does not require or consume the vendor refresh token',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => auth(current, { auth_mode: 'apikey' }),
() =>
JSON.stringify({
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() => {
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
});
},
(error) => {
assert.match(error.message, /sign-in has expired/);
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
return true;
},
);
}
const noExp = jwt({ sub: 'fixture' });
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
assert.equal(
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs }),
}),
noExp,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
}),
/sign-in has expired/,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => {
throw new Error('missing');
},
}),
/sign-in is unavailable/,
);
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
);
assert.equal(checked.length, 1);
const child = new EventEmitter();
let unref = 0;
child.unref = () => {
unref += 1;
};
const calls = [];
const pending = startCodexChatGptLogin({
executable: '/fixture/codex',
home: '/home/fixture',
environment: {
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
OPENAI_API_KEY: 'fixture-api-key',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
},
spawnImpl(command, args, options) {
calls.push({ command, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
const login = await pending;
assert.equal(unref, 1);
assert.equal(calls[0].command, '/fixture/codex');
assert.deepEqual(calls[0].args, ['login']);
assert.equal(calls[0].options.detached, true);
assert.equal(calls[0].options.stdio, 'ignore');
assert.deepEqual(
calls[0].options.env,
{
PATH: '/fixture',
https_proxy: 'http://proxy.fixture:8080',
CODEX_HOME: path.join('/home/fixture', '.codex'),
},
'provider secrets stay out of the login process',
);
child.emit('exit', 1);
assert.deepEqual(await login.completion, { exitCode: 1 });
});
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
await assert.rejects(
startCodexChatGptLogin({
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
home: '/home/fixture',
executable: 'codex',
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
}),
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
);
});
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
const child = new EventEmitter();
const environment = { CODEX_HOME: './fixture-codex' };
const login = await startCodexChatGptLogin({
environment,
executable: 'codex',
spawnImpl(_command, _args, options) {
assert.equal(
options.env.CODEX_HOME,
path.resolve(environment.CODEX_HOME),
);
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.equal(
environment.CODEX_HOME,
'./fixture-codex',
'caller environment is unchanged',
);
child.emit('exit', 0);
assert.deepEqual(await login.completion, { exitCode: 0 });
});
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
child.emit('error', new Error('private-token-fixture')),
);
return child;
},
}),
(error) =>
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
!error.message.includes('private-token-fixture'),
);
const running = new EventEmitter();
let cancelled = 0;
running.kill = (signal) => {
assert.equal(signal, 'SIGTERM');
cancelled += 1;
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
return running;
},
});
login.cancel();
assert.equal(cancelled, 1);
assert.deepEqual(await login.completion, { exitCode: null });
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { createDirectionsSource } from '../layers/directions/source.js';
/**
* @module directions
* @description Service bundle for the Directions layer.
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
}
export {
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
export function patchRequestedIdSwitches(previous, applied, requested) {
const current = decodeIdSwitches(applied);
const next = new Map(
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
);
let changed = false;
for (const [id, on] of current) {
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
next.set(id, on);
changed = true;
}
}
return changed ? encodeIdSwitches(next) : undefined;
}
+11 -3
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -411,10 +412,17 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
// Off until lit: every Street View panorama is billed to the Maps key.
booleanOption('google', 'g', false),
Object.freeze({
key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r',
defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
+80 -5
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
@@ -2670,14 +2671,14 @@ test('Street Level: every option round-trips through a share link and stored sta
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
google: true,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
google: true,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
@@ -2705,12 +2706,16 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
google: false,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
assert.equal(streetLevelOptions('0.g.1').google, true);
assert.equal(createDefaultLayerState().options['street-level'].google, false);
// Street View rides the shared provider-switch field, off unless lit.
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
assert.equal(
createDefaultLayerState().options['street-level'].providerSwitches,
'',
);
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal(
@@ -2718,3 +2723,73 @@ test('Street Level: a link without the provider switches keeps Mapillary on and
true,
);
});
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
const manager = new DataLayerManager({});
const providers = new Map([
['mapillary', true],
['alpha', true],
['beta', true],
]);
const filter = { pano: 'all', sinceDays: 0 };
for (const id of REGISTERED_LAYER_IDS) {
manager.register(
id === 'street-level'
? {
...fakeLayer(id),
getParams: () => encodeParams({ providers, filter }),
setParams(params) {
const decoded = decodeParams(params, {
providerIds: providers.keys(),
filter,
});
for (const [id, on] of decoded.providers) providers.set(id, on);
return true;
},
}
: fakeLayer(id),
);
}
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
try {
providers.set('beta', false); // Passive state does not belong to the next action.
assert.equal(
manager.setLayerParams(
'street-level',
{ alpha: false },
{ origin: 'user' },
),
true,
);
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
const restored = decodeParams(shared.options['street-level'], {
providerIds: ['beta', 'alpha'],
filter,
});
assert.deepEqual([...restored.providers], [['alpha', false]]);
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
manager.setLayerParams(
'street-level',
{ alpha: true },
{ origin: 'local-restore' },
);
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
} finally {
coordinator.destroy();
}
});
+16 -2
View File
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
// Packed options can persist only the subkeys owned by this request.
const requestedValue =
explicitlyRequested || implicitTrackingSync
? change.params[spec.key]
: spec.patchRequested?.(
nextOwnerOptions[spec.key],
change.params[spec.key],
requestedParams,
);
if (
!explicitlyRequested &&
!implicitTrackingSync &&
requestedValue === undefined
)
continue;
const value = spec.normalize(requestedValue);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
+6
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
+23 -1
View File
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
* for real-time data overlays on the CesiumJS globe.
*/
export class LayerLifecycle {
constructor(viewer, { allowQaRegistration = false } = {}) {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -228,6 +232,7 @@ export class LayerLifecycle {
_normalizedStats(entry) {
const moduleStats = this._moduleStats(entry);
const availability = this._sourceAvailability(entry.module.id);
const lifecycleLoading =
entry.lifecycleState === 'enabling' ||
entry.lifecycleState === 'disabling';
@@ -235,6 +240,13 @@ export class LayerLifecycle {
count: 0,
lastUpdate: null,
...moduleStats,
...(availability?.available === false
? {
status: 'unavailable',
sourceUnavailable: true,
error: availability.reason,
}
: {}),
loading: lifecycleLoading || moduleStats.loading === true,
refreshing: entry.refreshing || moduleStats.refreshing === true,
managerRefreshError: entry.managerRefreshError,
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
}
}
_sourceAvailability(layerId) {
return (
this._catalogSourceAvailability?.(layerId) ??
this.layers.get(layerId)?.module.getSourceAvailability?.()
);
}
async _visibilityBlockReason(change) {
const availability = this._sourceAvailability(change.layerId);
if (change.enabled && availability?.available === false)
return availability.reason || 'Data source unavailable';
for (const callback of this._visibilityGuards) {
try {
const result = await callback(change);
+2 -1
View File
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const layer = createDirectionsLayer({ services: {
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
+179
View File
@@ -1,4 +1,8 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import {
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './voice/cloudVoiceAuth.js';
/**
* The POWER UP surface — paste a key, get a power.
@@ -38,6 +42,52 @@ export function collectKeyUpdates(fields) {
return updates;
}
function sleepWithSignal(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function waitForChatGptOAuth({
fetchImpl,
signal,
timeoutMs = 120_000,
pollMs = 1_000,
now = () => Date.now(),
sleep = sleepWithSignal,
} = {}) {
const deadline = now() + Math.max(0, timeoutMs);
while (!signal?.aborted && now() <= deadline) {
const response = await fetchImpl('/api/realtime/oauth-status', {
cache: 'no-store',
signal,
});
const payload = await response.json().catch(() => ({}));
if (signal?.aborted) return false;
if (response.ok && payload.available) return true;
if (!response.ok || payload.loginFailed) {
throw new Error(
payload.error || 'Could not check ChatGPT sign-in. Try again.',
);
}
if (now() >= deadline) break;
await sleep(Math.max(0, pollMs), signal);
}
return false;
}
/**
* After the FIRST Google key lands, the restart's reload should boot the
* photoreal default — not faithfully restore the auto-selected keyless OSM
@@ -61,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
function updateCloudVoiceAuthControl(auth, busy = false) {
const mode = readStoredCloudVoiceAuthMode();
const state = auth.querySelector('.key-setup-cloud-auth-state');
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
state.textContent =
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
toggle.title =
mode === 'oauth'
? 'Use OPENAI_API_KEY for the next cloud voice session'
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
// Keep the control focusable while the delegated handler blocks repeats.
toggle.setAttribute('aria-disabled', String(busy));
}
/** Build one key row. All content is our own registry text, set via textContent. */
function buildRow(documentRef, key) {
const row = documentRef.createElement('section');
@@ -144,6 +209,19 @@ function buildRow(documentRef, key) {
}
row.append(fields);
}
if (key.id === 'openai') {
const auth = documentRef.createElement('div');
auth.className = 'key-setup-cloud-auth';
const state = documentRef.createElement('span');
state.className = 'key-setup-cloud-auth-state';
const toggle = documentRef.createElement('button');
toggle.type = 'button';
toggle.className = 'key-setup-cloud-auth-toggle';
toggle.dataset.cloudVoiceAuthToggle = 'true';
auth.append(state, toggle);
updateCloudVoiceAuthControl(auth);
row.append(auth);
}
return row;
}
@@ -224,8 +302,14 @@ export async function initKeySetup({
const statusLine = root.querySelector('[data-key-setup-status]');
const defaultStatusText = statusLine?.textContent || '';
let busy = false;
let oauthBusy = false;
let open = false;
const syncCloudVoiceAuth = () => {
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
updateCloudVoiceAuthControl(auth, oauthBusy);
};
const render = (nextStatus) => {
if (disposed) return;
status = nextStatus;
@@ -239,6 +323,7 @@ export async function initKeySetup({
rowsHost.textContent = '';
for (const key of status.keys || [])
rowsHost.append(buildRow(documentRef, key));
syncCloudVoiceAuth();
};
const visible = () =>
@@ -367,6 +452,100 @@ export async function initKeySetup({
applyButton?.addEventListener('click', onApply);
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const authButton = event.target?.closest?.(
'[data-cloud-voice-auth-toggle]',
);
if (authButton && !disposed) {
if (oauthBusy) return;
void (async () => {
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
syncCloudVoiceAuth();
say('Cloud voice will use OPENAI_API_KEY on the next session.');
return;
}
oauthBusy = true;
syncCloudVoiceAuth();
say('Checking local ChatGPT OAuth sign-in…');
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw new Error(
payload.error || 'Could not check ChatGPT sign-in.',
);
if (payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? 'ChatGPT sign-in expired. Opening sign-in again…'
: 'Opening ChatGPT sign-in in your browser…',
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok) {
say(
loginPayload.error ||
payload.error ||
'Could not start ChatGPT sign-in on this machine.',
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say(
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
syncCloudVoiceAuth();
say(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(`OAuth check failed: ${error?.message || error}`);
} finally {
oauthBusy = false;
if (!disposed) syncCloudVoiceAuth();
}
})();
return;
}
const button = event.target?.closest?.('[data-key-setup-remove]');
if (disposed || !button || busy) return;
let envVars = [];
+59
View File
@@ -5,6 +5,7 @@ import {
keySetupChipLabel,
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
waitForChatGptOAuth,
} from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
@@ -61,6 +62,64 @@ test('collectKeyUpdates keeps only non-empty trimmed values', () => {
assert.deepEqual(collectKeyUpdates(null), {});
});
test('OAuth login polling stops as soon as local ChatGPT auth becomes available', async () => {
let checks = 0;
let clock = 0;
const available = await waitForChatGptOAuth({
fetchImpl: async () => {
checks += 1;
return {
ok: true,
json: async () => ({ available: checks >= 3 }),
};
},
timeoutMs: 10_000,
pollMs: 100,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
});
assert.equal(available, true);
assert.equal(checks, 3);
});
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
{ ok: false, payload: { error: 'Sign-in is local only.' } },
]) {
let checks = 0;
await assert.rejects(waitForChatGptOAuth({
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
sleep: () => assert.fail('failed login must stop polling'),
}), { message: result.payload.error });
assert.equal(checks, 1);
}
});
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0;
let checks = 0;
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
assert.equal(checks, 2);
const controller = new AbortController();
controller.abort();
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
assert.equal(checks, 2, 'aborted polling does not fetch');
});
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
const controller = new AbortController();
const available = await waitForChatGptOAuth({
signal: controller.signal,
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
sleep: () => assert.fail('aborted polling must not continue'),
});
assert.equal(available, false);
});
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
assert.ok(stripped !== null);
+1 -1
View File
@@ -55,7 +55,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'openai',
title: 'OPENAI',
unlocks: 'Voice control — talk to the planet',
unlocks: 'Voice control — API key or ChatGPT OAuth',
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
+11 -18
View File
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
* @param {{lat:number, lon:number}} b
* @returns {string}
*/
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
export { directionsRequestUrl } from './source.js';
/**
* Validate a proxy payload into the route record the layer keeps, or null.
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
* route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter.
* @param {{services: object}} options
* @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers.
*/
export function createDirectionsLayer({ services }) {
export function createDirectionsLayer({ services, source }) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
/**
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
* tests swap it. Resolved on use, not at import, because the services arrive
@@ -806,18 +805,12 @@ export function createDirectionsLayer({ services }) {
_error = null;
notifyRow();
try {
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
const payload = await source.getRoute(
{ mode, a: _a, b: _b },
{
signal: controller.signal,
},
);
if (seq !== _routeSeq || !_enabled) return;
const route = normalizeRoutePayload(payload, mode);
if (!route) {
+34
View File
@@ -0,0 +1,34 @@
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** Acquire route records using caller-owned transport and an optional endpoint. */
export function createDirectionsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/route',
} = {}) {
return {
async getRoute({ mode, a, b }, { signal } = {}) {
signal?.throwIfAborted();
const query = directionsRequestUrl(mode, a, b).split('?')[1];
const response = await fetchImpl(`${endpoint}?${query}`, {
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
signal?.throwIfAborted();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
return payload;
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDirectionsSource } from './source.js';
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
test('route acquisition uses only the supplied transport and endpoint', async () => {
const controller = new AbortController();
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
const calls = [];
const source = createDirectionsSource({
endpoint: 'https://routes.example/route',
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
});
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
assert.equal(calls.length, 1);
const [url, init] = calls[0];
assert.equal(new URL(url).origin, 'https://routes.example');
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
assert.equal(init.signal, controller.signal);
assert.equal(init.headers.Accept, 'application/json');
});
test('aborting during route parsing discards the result', async () => {
const controller = new AbortController();
const source = createDirectionsSource({ fetchImpl: async () => ({
ok: true, json: async () => { controller.abort(); return { ok: true }; },
}) });
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
});
test('route failures are surfaced without fallback requests', async () => {
for (const status of [429, 503]) {
let calls = 0;
const source = createDirectionsSource({ fetchImpl: async () => {
calls++;
return Response.json({}, { status });
} });
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
assert.equal(calls, 1);
}
});
+4 -2
View File
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
*/
export function createRecentImageryRenderer({
cesium = Cesium,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
url: tileTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
box.east,
box.north,
),
credit: 'NASA GIBS',
credit,
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
+20 -1
View File
@@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
...options,
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
const { renderer, globe } = fixture({
tileTemplate: (product, day) =>
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.equal(renderer.showSlot('a', S30, BOX), true);
const layer = globe.layers[0];
assert.equal(
layer.provider.options.url,
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
);
assert.equal(layer.provider.options.credit, 'Example imagery');
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(layer.destroyed, true);
});
+32
View File
@@ -0,0 +1,32 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
export function createRecentImagerySource({
fetchImpl = (...args) => globalThis.fetch(...args),
search = searchHls,
thumbnailUrl = wvsSnapshotUrl,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
} = {}) {
return {
credit,
searchHls: (query) => search({ ...query, fetchImpl }),
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
thumbnailUrl({
product: candidate.product,
day: candidate.day,
box,
width: size,
height: size,
}),
{ signal },
);
signal?.throwIfAborted();
return response;
},
getTileTemplate: (product, day) => tileTemplate(product, day),
};
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRecentImagerySource } from './source.js';
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
const controller = new AbortController();
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
const candidate = { product: 'S30', day: '2026-10-01' };
const calls = [];
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
const source = createRecentImagerySource({
fetchImpl,
search: async (request) => {
assert.equal(request.fetchImpl, fetchImpl);
assert.equal(request.signal, controller.signal);
assert.equal(request.box, query.box);
return [candidate];
},
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.deepEqual(await source.searchHls(query), [candidate]);
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
assert.equal(calls[0][1].signal, controller.signal);
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
assert.equal(source.credit, 'Example imagery');
});
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
const controller = new AbortController();
let calls = 0;
const source = createRecentImagerySource({
thumbnailUrl: () => 'https://imagery.example/thumbnail',
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
});
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 1);
});
+6 -8
View File
@@ -8,7 +8,8 @@
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
import { thumbnailOrder } from './model.js';
import { createRecentImagerySource } from './source.js';
const EMPTY = Object.freeze({
status: 'unknown',
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
const response = await requestThumbnail(entry.candidate, entry.box, {
size,
signal: controller.signal,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
tracked: 1,
});
});
test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => {
const calls = [];
const { loader, fetch, revoked } = fixture({
requestThumbnail: async (...args) => {
calls.push(args);
return response({ headers: { 'Data-Present': 'true' } });
},
});
const entry = candidate('S30', '2026-09-18');
loader.request(entry, BOX, 0);
await settle();
assert.equal(fetch.calls.length, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], entry);
assert.deepEqual(calls[0][1], BOX);
assert.ok(calls[0][2].signal instanceof AbortSignal);
assert.equal(loader.get(entry.key).status, 'present');
loader.destroy();
assert.deepEqual(revoked, ['blob:1']);
});
+10
View File
@@ -444,6 +444,16 @@ export function createStreetLevelLayer({
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
getSourceAvailability() {
const available = definitionsFrozen.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
reason: available
? null
: 'No street-level imagery providers configured',
};
},
init(viewer) {
if (state.initialized)
+14
View File
@@ -665,3 +665,17 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
layer.setProviderEnabled('google', true);
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
+16 -5
View File
@@ -1,13 +1,19 @@
import { normalizeFilter } from './filter.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const switches = decodeIdSwitches(source.providerSwitches);
switches.delete('mapillary');
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
providers.set(id, source[id]);
}
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
+7 -1
View File
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
);
});
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeIdSwitches,
patchRequestedIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { validateProviders } from './registry.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
const filter = { pano: 'flat', sinceDays: 365 };
test('custom switches survive sharing and storage without depending on provider order', () => {
const providers = [
['panoramax', false],
['example', true],
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
options.providerSwitches,
);
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = options;
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
assert.ok(
params.toString().includes('0.r.example-1*panoramax-0'),
'custom switches need no percent escaping',
);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
]) {
const decoded = decodeParams(restored.options['street-level'], {
providerIds: ['example', 'mapillary', 'panoramax'],
filter: { pano: 'all', sinceDays: 0 },
});
assert.deepEqual(
[...decoded.providers],
[
['example', true],
['mapillary', false],
['panoramax', false],
],
);
assert.deepEqual(decoded.filter, filter);
assert.deepEqual(
[
...decodeParams(restored.options['street-level'], {
providerIds: ['panoramax'],
filter,
}).providers,
],
[['panoramax', false]],
);
assert.equal(
decodeParams(restored.options['street-level'], {
providerIds: [],
filter,
}).providers.size,
0,
);
}
});
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
for (const [lo, on] of [
['', true],
['0.m.0', false],
]) {
const state = decodeLayerStateParams(
new URLSearchParams({ v: '2', l: '0', lo }),
);
const restored = decodeParams(state.options['street-level'], {
providerIds: ['mapillary', 'example'],
filter,
});
assert.deepEqual([...restored.providers], [['mapillary', on]]);
}
});
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example-0*example-1',
'mapillary-0',
'__proto__-1',
'a-2',
'a-1*',
'a.b-1',
'a_b-0',
`${'a'.repeat(255)}-1`,
null,
{},
]) {
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary-0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
const switches = [
['provider-0', false],
['provider-1', true],
['a-b-c', true],
];
const encoded = encodeIdSwitches(switches);
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
const decoded = decodeIdSwitches(encoded);
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
});
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
const previous = `${'x'.repeat(254)}-1`;
assert.equal(normalizeIdSwitches(previous), previous);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
'alpha-0',
);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
undefined,
);
});
+18 -3
View File
@@ -1,3 +1,5 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -11,7 +13,7 @@
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms).
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); match the share-link option default.
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); a share link without its switch keeps it.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -91,8 +93,8 @@ const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
if (!Array.isArray(providers) || providers.length === 0)
throw new TypeError('Street Level needs at least one imagery provider');
if (!Array.isArray(providers))
throw new TypeError('Street Level providers must be an array');
const ids = new Set();
const prefixes = [];
for (const provider of providers) {
@@ -130,6 +132,19 @@ export function validateProviders(providers) {
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
try {
encodeIdSwitches(
providers
.filter(({ id }) => id !== 'mapillary')
.map(({ id }) => [id, true]),
);
} catch (error) {
if (!(error instanceof RangeError)) throw error;
throw new RangeError(
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
);
}
return Object.freeze([...providers]);
}
+8 -3
View File
@@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => {
);
});
test('an empty list, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders([]), /at least one/);
assert.throws(() => validateProviders(null), /at least one/);
test('an empty provider list is valid and has no key requirement', () => {
assert.deepEqual(validateProviders([]), []);
assert.ok(Object.isFrozen(validateProviders([])));
assert.equal(requiresKeyIdFor([]), null);
});
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders(null), /array/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
});
+4 -1
View File
@@ -27,7 +27,10 @@ export function summarizeCoverage(providers) {
return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading),
hint: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
hint:
providers.length === 0
? 'No street-level imagery providers configured'
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
+1 -1
View File
@@ -109,7 +109,7 @@ export function createIngestion({
feed.acceptedRowCount = snapshot.acceptedRowCount;
feed.partial = payload?.complete === false;
if (snapshot.acceptedRowCount === 0) {
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
feed.count = readCount();
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
+44
View File
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
layer.destroy();
assert.equal(layer.getStats().partial, false);
});
test('authoritative empty coverage clears old contacts without declaring an outage', async () => {
let current = snapshot([observation('111')]);
const { layer } = setup({
async getSnapshot() {
return current;
},
});
await layer.update();
current = snapshot([], { observedAtMs: null, lastMessageAt: 500 });
await layer.update();
assert.ok(!layer.hasContact('111'));
assert.equal(layer.getStats().count, 0);
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().stale, false);
assert.equal(layer.getStats().lastUpdate, null);
});
test('empty coverage cannot hide stale, incomplete or rejected source data', async () => {
for (const patch of [
{ freshness: 'unknown' },
{ freshness: 'stale' },
{ complete: false },
{ rawRowCount: 1 },
{ transportStatus: 'stale' },
{ lastMessageAt: null },
]) {
let current = snapshot([observation('111')]);
const { layer } = setup({
async getSnapshot() {
return current;
},
});
await layer.update();
current = snapshot([], {
observedAtMs: null,
lastMessageAt: 500,
...patch,
});
await layer.update();
assert.equal(layer.hasContact('111'), true, JSON.stringify(patch));
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
+13 -3
View File
@@ -119,7 +119,16 @@ export function createQueries({
typeof payload?.status === 'string' ? payload.status : null;
const lastMessageAt = payload?.lastMessageAt ?? null;
const acceptedRowCount = acceptedRows.length;
const emptyCoverage =
rawRows.length === 0 &&
payload?.rawRowCount === 0 &&
payload?.complete === true &&
payload?.freshness === 'current' &&
AIS_HEALTHY_STATUSES.has(transportStatus) &&
Number.isFinite(Number(lastMessageAt)) &&
Number(lastMessageAt) > 0;
return {
emptyCoverage,
transportStatus,
lastMessageAt,
rawRows,
@@ -130,9 +139,10 @@ export function createQueries({
? payload.rawRowCount
: rawRows.length,
acceptedRowCount,
error:
deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
error: emptyCoverage
? null
: deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
};
}
+7
View File
@@ -274,6 +274,13 @@ test('doctor describes the credential ladder without exposing values', () => {
);
assert.match(capabilities.map, /Bing and world-terrain stacks/);
assert.equal(capabilities.voice, 'available');
assert.equal(
buildCapabilitySummary({
...credentials,
OPENAI_API_KEY: { configured: false },
}).voice,
'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
);
assert.match(capabilities.missions, /token allowance/);
assert.equal(
capabilities.flights,
+9 -2
View File
@@ -214,11 +214,18 @@ function decodeLayers(bytes, z, x, y, layerNames) {
return { roads, military };
}
/** Construct an immutable-version road/military tile source without starting I/O. */
/**
* Construct an immutable-version road/military tile source without starting I/O.
*
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
* never a silent fall back to the default host (#933). Passing
* `allowedOrigin` explicitly still overrides both.
*/
export function createOpenFreeMapSource(options = {}) {
return createVectorTileSource({
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
allowedOrigin: 'https://tiles.openfreemap.org',
decode: decodeOpenFreeMapTile,
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
+38
View File
@@ -320,3 +320,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri
assert.ok(records.some((r) => r.area === area && !r.drivable));
}
});
test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => {
const urls = [];
const mirror = 'http://127.0.0.1:12348';
const source = createOpenFreeMapSource({
fetchImpl: async (url) => {
urls.push(url);
return url.endsWith('/planet')
? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] })
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
},
tileJsonUrl: `${mirror}/planet`,
});
await source.fetchBounds(
{ south: 30.264, north: 30.271, west: -97.75, east: -97.74 },
{ zoom: 14 },
);
assert.ok(urls.length > 1, 'tiles were fetched');
assert.deepEqual(
urls.filter((url) => !url.startsWith(mirror)),
[],
'a repointed source must not fetch from tiles.openfreemap.org',
);
});
test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => {
// A transparent caching proxy returns upstream's TileJSON unchanged, whose
// tiles[0] is an absolute upstream URL. That must be an error, not 100% of
// tile traffic quietly leaving the configured mirror.
const source = createOpenFreeMapSource({
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
}),
tileJsonUrl: 'http://127.0.0.1:12348/planet',
});
await assert.rejects(source.getMetadata(), { retryable: false });
});
+17 -1
View File
@@ -16,11 +16,27 @@ export function validTileBounds(box) {
);
}
/**
* The origin a configured TileJSON is served from, or undefined when there is
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
* the old host: the origin check below then names the mismatch instead of
* passing it (#933).
*/
function tileJsonOrigin(tileJsonUrl) {
if (typeof tileJsonUrl !== 'string') return undefined;
try {
return new URL(tileJsonUrl).origin;
} catch {
return undefined;
}
}
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
export function createVectorTileSource({
tileJsonUrl,
template,
allowedOrigin,
allowedOrigin = tileJsonOrigin(tileJsonUrl),
decode,
fetchImpl = (...args) => globalThis.fetch(...args),
maxTiles = 16,
+42
View File
@@ -203,3 +203,45 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
'over-budget selection is rejected before fetching',
);
});
test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => {
// Without an explicit allowedOrigin the mirror's own TileJSON is honoured…
const mirrored = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
decode: () => [],
fetchImpl: async () =>
Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }),
});
assert.equal(
(await mirrored.getMetadata()).template,
'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf',
);
// …while a TileJSON that names a different host is refused rather than
// silently fetched, which is what the origin check is for.
const foreign = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
decode: () => [],
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
}),
});
await assert.rejects(foreign.getMetadata(), { retryable: false });
});
test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => {
const source = createVectorTileSource({
tileJsonUrl: 'http://127.0.0.1:12348/planet',
allowedOrigin: 'https://tiles.example',
decode: () => [],
fetchImpl: async () =>
Response.json({
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
}),
});
assert.equal(
(await source.getMetadata()).template,
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
);
});
+4
View File
@@ -17,6 +17,8 @@ export function createStandaloneApplication({
cesiumToken,
geospatial = {},
voice = {},
sources = {},
streetLevelProviders,
allowQaRegistration = false,
}) {
if (constructed)
@@ -47,6 +49,8 @@ export function createStandaloneApplication({
loaderStatus,
});
catalog = createStandaloneCatalog({
sources,
streetLevelProviders,
nepalBoundaryResolver: (signal) =>
scene.operations.annotationResolver.resolveRegionRingForQuery(
'Nepal',
+4 -1
View File
@@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js';
/** Create fresh layer instances using the existing standalone source choices. */
export function createStandaloneCatalog({
nepalBoundaryResolver,
sources = {},
streetLevelProviders,
signal = new AbortController().signal,
surface = createSurfaceServices({
terrainSource: createApplicationRequestServices().terrain,
@@ -16,7 +18,8 @@ export function createStandaloneCatalog({
return createApplicationCatalog({
nepalBoundaryResolver,
surface,
sources: createStandaloneLayerSources(),
sources: createStandaloneLayerSources(sources),
streetLevelProviders,
signal,
vesselOptions: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
+13 -2
View File
@@ -1,3 +1,5 @@
import { createDirectionsSource } from '../layers/directions/source.js';
import { createRecentImagerySource } from '../layers/recentImagery/source.js';
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import {
createFlightSource,
@@ -22,10 +24,14 @@ import { createReferenceSources } from '../sources/reference.js';
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
/** Select standalone providers without starting their acquisition. */
export function createStandaloneLayerSources() {
export function createStandaloneLayerSources(overrides = {}) {
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
throw new TypeError('Source overrides must be an object');
const mapTiles = createOpenFreeMapSource();
return {
const defaults = {
...createReferenceSources(),
directions: createDirectionsSource(),
'recent-imagery': createRecentImagerySource(),
flights: createFlightSource(),
military: createMilitarySource(),
vessels: createVesselSource({
@@ -51,4 +57,9 @@ export function createStandaloneLayerSources() {
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
}),
};
for (const name of Object.keys(overrides)) {
if (!Object.hasOwn(defaults, name))
throw new TypeError(`Unknown source: ${name}`);
}
return { ...defaults, ...overrides };
}
+382 -1
View File
@@ -41,6 +41,7 @@ function request(
url = '/',
body = '',
origin = 'http://localhost:4173',
remoteAddress = '127.0.0.1',
} = {},
) {
return new Promise((resolve, reject) => {
@@ -53,7 +54,7 @@ function request(
origin,
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
socket: { remoteAddress },
});
const headers = {};
const res = {
@@ -199,6 +200,386 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
});
test('Realtime handler keeps API key as default and uses OAuth only when requested', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
let oauthReads = 0;
const authorizations = [];
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveApiKey: () => 'fixture-api-key',
resolveOAuthAccessToken: () => {
oauthReads += 1;
return 'fixture-oauth-token';
},
fetchImpl: async (_url, options) => {
authorizations.push(options.headers.Authorization);
return Response.json({ value: 'fixture-ephemeral' });
},
},
}),
);
const token = routes.get('/api/realtime/token');
const apiResponse = await request(token);
assert.equal(apiResponse.status, 200);
assert.equal(apiResponse.headers['x-gev-voice-auth'], 'env');
assert.equal(oauthReads, 0, 'default cloud voice does not read OAuth');
assert.equal(authorizations.at(-1), 'Bearer fixture-api-key');
const oauthResponse = await request(token, { url: '/?auth=oauth' });
assert.equal(oauthResponse.status, 200);
assert.equal(oauthResponse.headers['x-gev-voice-auth'], 'codex-oauth');
assert.equal(oauthReads, 1);
assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token');
});
test('a Codex OAuth pin refuses API-key minting without silently switching modes', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const authorizations = [];
const routes = install(
openAiRealtimeProxy({
realtime: {
environment: { GEV_PREFER_CODEX_OAUTH: 'true' },
resolveApiKey: () => assert.fail('pinned OAuth cannot read an API key'),
resolveOAuthAccessToken: () => 'fixture-oauth-token',
fetchImpl: async (_url, options) => {
authorizations.push(options.headers.Authorization);
return Response.json({ value: 'fixture-ephemeral' });
},
},
}),
);
const token = routes.get('/api/realtime/token');
const refused = await request(token);
assert.equal(refused.status, 503);
assert.equal(refused.json().code, 'CODEX_OAUTH_REQUIRED');
assert.equal(authorizations.length, 0);
assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200);
assert.deepEqual(authorizations, ['Bearer fixture-oauth-token']);
});
test('OAuth status and token minting never expose local OAuth to remote clients', async () => {
let oauthReads = 0;
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
oauthReads += 1;
return 'must-not-be-read-remotely';
},
fetchImpl: () =>
assert.fail('remote OAuth request must not reach OpenAI'),
},
}),
);
const remote = { remoteAddress: '192.168.1.20' };
assert.equal(
(await request(routes.get('/api/realtime/oauth-status'), remote)).status,
403,
);
assert.equal(
(
await request(routes.get('/api/realtime/token'), {
...remote,
url: '/?auth=oauth',
})
).status,
403,
);
assert.equal(oauthReads, 0);
});
test('OAuth status reports local sign-in availability without returning the token', async () => {
const routes = install(
openAiRealtimeProxy({
realtime: { resolveOAuthAccessToken: () => 'fixture-oauth-secret' },
}),
);
const response = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(response.status, 200);
assert.deepEqual(response.json(), { available: true });
assert.equal(response.body.includes('fixture-oauth-secret'), false);
});
test('OAuth status and token minting report expired sign-in as re-auth required', async () => {
const expired = () => {
const error = new Error('ChatGPT sign-in has expired.');
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
throw error;
};
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: expired,
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
},
}),
);
const status = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(status.status, 200);
assert.deepEqual(status.json(), {
available: false,
error: 'ChatGPT sign-in has expired.',
code: 'CODEX_OAUTH_REAUTH_REQUIRED',
reauthRequired: true,
});
const token = await request(routes.get('/api/realtime/token'), {
url: '/?auth=oauth',
});
assert.equal(token.status, 503);
assert.equal(token.json().code, 'CODEX_OAUTH_REAUTH_REQUIRED');
});
test('OAuth login endpoint shares pending attempts and never exposes credentials', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let starts = 0;
let spawnReady;
let exit;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
throw new Error('not signed in');
},
startOAuthLogin: async () => {
starts += 1;
await new Promise((resolve) => {
spawnReady = resolve;
});
return {
completion: new Promise((resolve) => {
exit = resolve;
}),
cancel() {},
};
},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const handler = routes.get('/api/realtime/oauth-login');
const firstRequest = request(handler, { method: 'POST' });
const concurrent = await request(handler, { method: 'POST' });
assert.equal(concurrent.status, 202);
assert.equal(concurrent.json().started, false);
assert.equal(starts, 1, 'reserve the attempt before awaiting spawn');
spawnReady();
const started = await firstRequest;
assert.equal(started.status, 202);
assert.deepEqual(started.json(), {
available: false,
started: true,
pending: true,
});
assert.equal(starts, 1);
t.mock.timers.tick(31_000);
const duplicate = await request(handler, { method: 'POST' });
assert.equal(duplicate.status, 202);
assert.equal(duplicate.json().pending, true);
assert.equal(starts, 1, 'rapid retries must not spawn another login');
const remote = await request(handler, {
method: 'POST',
remoteAddress: '192.168.1.20',
});
assert.equal(remote.status, 403);
assert.equal(starts, 1);
const crossOrigin = await request(handler, {
method: 'POST',
origin: 'https://example.com',
});
assert.equal(crossOrigin.status, 403);
assert.equal(starts, 1);
exit({ exitCode: 1 });
await Promise.resolve();
const failed = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(failed.json().loginFailed, true);
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
});
test('OAuth status follows process completion, permits retry, and checks credential storage', async (t) => {
let token = null;
let exit;
let starts = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => token,
startOAuthLogin: async () => {
starts += 1;
return {
completion: new Promise((resolve) => {
exit = resolve;
}),
cancel() {},
};
},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
const status = () => request(routes.get('/api/realtime/oauth-status'));
await launch();
exit({ exitCode: 0 });
await Promise.resolve();
assert.equal(
(await status()).json().code,
'CODEX_OAUTH_STORAGE_UNAVAILABLE',
'exit success alone is not usable sign-in',
);
await launch();
assert.equal(starts, 2, 'failed attempts can be retried immediately');
token = 'fixture-private-token';
exit({ exitCode: 0 });
await Promise.resolve();
const ready = await status();
assert.deepEqual(ready.json(), { available: true });
assert.equal(ready.body.includes(token), false);
});
test('OAuth login timeout and server shutdown cancel the owned process', async () => {
let timeout;
let cancelled = 0;
let cleared = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => null,
startOAuthLogin: async () => ({
completion: new Promise(() => {}),
cancel() {
cancelled += 1;
},
}),
setTimer(callback, duration) {
timeout = callback;
assert.equal(duration, 120_000);
return 1;
},
clearTimer() {
cleared += 1;
},
},
});
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
await launch();
timeout();
assert.equal(cancelled, 1);
const status = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(status.json().code, 'CODEX_OAUTH_LOGIN_TIMEOUT');
await launch();
plugin.closeBundle();
plugin.closeBundle();
assert.equal(cancelled, 2, 'shutdown is idempotent');
assert.equal(cleared, 2);
});
test('OAuth launch errors are safe and cross-origin status requests cannot read credentials', async () => {
let reads = 0;
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken() {
reads += 1;
return null;
},
startOAuthLogin: async () => {
throw new Error('private-token-fixture');
},
},
}),
);
const remote = await request(routes.get('/api/realtime/oauth-status'), {
origin: 'https://example.com',
});
assert.equal(remote.status, 403);
assert.equal(reads, 0);
const failed = await request(routes.get('/api/realtime/oauth-login'), {
method: 'POST',
});
assert.equal(failed.status, 503);
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
assert.equal(failed.body.includes('private-token-fixture'), false);
});
test('a login that spawns after timeout is cancelled and cannot replace a retry', async (t) => {
let timeout;
let releaseSpawn;
let cancelled = 0;
let launches = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => null,
startOAuthLogin: async () => {
launches += 1;
if (launches === 1)
await new Promise((resolve) => {
releaseSpawn = resolve;
});
return {
completion: new Promise(() => {}),
cancel() {
cancelled += 1;
},
};
},
setTimer(callback) {
timeout = callback;
return 1;
},
clearTimer() {},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
const first = launch();
timeout();
await launch();
releaseSpawn();
await first;
assert.equal(launches, 2);
assert.equal(cancelled, 1, 'late process must not become an orphan');
const status = (
await request(routes.get('/api/realtime/oauth-status'))
).json();
assert.equal(status.pending, true);
assert.equal(
status.loginFailed,
undefined,
'the older attempt cannot fail the retry',
);
});
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {
const handler = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => 'fixture-existing-secret',
startOAuthLogin: () =>
assert.fail('existing auth must not launch login'),
},
}),
).get('/api/realtime/oauth-login');
const response = await request(handler, { method: 'POST' });
assert.equal(response.status, 200);
assert.deepEqual(response.json(), {
available: true,
started: false,
pending: false,
});
assert.equal(response.body.includes('fixture-existing-secret'), false);
});
test('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
+15 -5
View File
@@ -595,7 +595,12 @@ export class LayerPanel {
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const unavailable = layer.stats?.sourceUnavailable === true;
const feedState = unavailable
? 'unavailable'
: layer.enabled
? layerFeedState(layer.stats)
: 'off';
const transitioning =
layer.lifecycleState === 'enabling' ||
layer.lifecycleState === 'disabling';
@@ -608,7 +613,7 @@ export class LayerPanel {
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(
`feed-${state}`,
layer.enabled && !uncertain && feedState === state,
(layer.enabled || unavailable) && !uncertain && feedState === state,
);
}
button.dataset.feedState = transitioning
@@ -620,16 +625,21 @@ export class LayerPanel {
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute(
'aria-disabled',
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: uncertain
? 'UNCERTAIN'
: layer.enabled
: layer.enabled || unavailable
? FEED_STATE_LABELS[feedState]
: 'OFF';
const keyGuidance = layerKeyRequirementTooltip(layer);
const keyGuidance = unavailable
? String(layer.stats.error || 'Data source unavailable')
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
+30
View File
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
globalThis.document = previousDocument;
}
});
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const layer = {
id: 'flights',
name: 'Flights',
enabled: false,
stats: {
sourceUnavailable: true,
status: 'unavailable',
error: 'Data source not configured: flights',
},
};
LayerPanel.prototype._syncToggleButton(button, layer);
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(attrs.get('aria-disabled'), 'true');
assert.match(button.title, /not configured/);
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
assert.equal(
attrs.get('aria-disabled'),
'false',
'an active layer can always be switched off',
);
});
+2 -1
View File
@@ -184,6 +184,7 @@ function presentNearest(state) {
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
@@ -215,7 +216,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
enabled,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired,
controlsDisabled: keyRequired || state.providers.length === 0,
providers: presentProviders(state),
error: keyMissing
? null
+17
View File
@@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot;
test('an empty composition explains unavailable imagery even while disabled', () => {
for (const enabled of [false, true]) {
const view = presentStreetLevelPanel(
snapshot({
enabled,
providers: [],
coverage: { hint: 'No street-level imagery providers configured' },
legend: [],
}),
);
assert.equal(view.controlsDisabled, true);
assert.equal(view.keyRequired, false);
assert.deepEqual(view.providers, []);
assert.equal(view.meta, 'No street-level imagery providers configured');
}
});
function snapshot(overrides = {}) {
const base = {
enabled: false,
+37
View File
@@ -300,6 +300,43 @@
color: rgba(148, 178, 188, 0.42);
}
.key-setup-cloud-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-setup-cloud-auth-state {
color: rgba(190, 214, 222, 0.7);
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.05rem;
}
.key-setup-cloud-auth-toggle {
margin-left: auto;
padding: 0.28rem 0.55rem;
color: rgba(54, 220, 255, 0.9);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.06rem;
background: rgba(54, 220, 255, 0.08);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.32rem;
cursor: pointer;
}
.key-setup-cloud-auth-toggle:hover,
.key-setup-cloud-auth-toggle:focus-visible {
border-color: rgba(54, 220, 255, 0.65);
outline: none;
}
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
cursor: wait;
opacity: 0.5;
}
.key-setup-footer {
margin-top: 0.85rem;
}
+41
View File
@@ -0,0 +1,41 @@
export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']);
export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key';
export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth';
function storageRef(storage) {
if (storage) return storage;
try {
return typeof localStorage === 'undefined' ? null : localStorage;
} catch {
return null;
}
}
export function normalizeCloudVoiceAuthMode(mode) {
const normalized = String(mode || '')
.trim()
.toLowerCase();
return CLOUD_VOICE_AUTH_MODES.includes(normalized)
? normalized
: DEFAULT_CLOUD_VOICE_AUTH_MODE;
}
export function readStoredCloudVoiceAuthMode(storage) {
try {
return normalizeCloudVoiceAuthMode(
storageRef(storage)?.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY),
);
} catch {
return DEFAULT_CLOUD_VOICE_AUTH_MODE;
}
}
export function writeStoredCloudVoiceAuthMode(mode, storage) {
const resolved = normalizeCloudVoiceAuthMode(mode);
try {
storageRef(storage)?.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, resolved);
} catch {
/* best effort */
}
return resolved;
}
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CLOUD_VOICE_AUTH_STORAGE_KEY,
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './cloudVoiceAuth.js';
function memoryStorage(seed = {}) {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
};
}
test('cloud voice auth defaults to API key and persists OAuth independently', () => {
const storage = memoryStorage();
assert.equal(readStoredCloudVoiceAuthMode(storage), 'api-key');
assert.equal(writeStoredCloudVoiceAuthMode('oauth', storage), 'oauth');
assert.equal(readStoredCloudVoiceAuthMode(storage), 'oauth');
assert.equal(writeStoredCloudVoiceAuthMode('unknown', storage), 'api-key');
assert.equal(
readStoredCloudVoiceAuthMode(
memoryStorage({ [CLOUD_VOICE_AUTH_STORAGE_KEY]: 'oauth' }),
),
'oauth',
);
});
+48
View File
@@ -3195,6 +3195,54 @@ test('F1: the toggle still records the next-session preference while live', () =
assert.match(ui.tierButton.title, /this session stays on/i);
});
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
const { controller, ui } = costControllerHarness();
controller.status = 'listening';
controller._cost.cloudVoiceAuth = 'oauth';
controller._cost.sessionCloudVoiceAuth = 'oauth';
controller.syncCostUi();
const before = controller.costTracker.state();
assert.equal(ui.costValue.hidden, false);
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
responses: 1,
input: 0,
output: 1562500,
captions: 0,
costKnown: false,
});
const after = controller.costTracker.state();
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(controller.costCapStopped, false);
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
controller.dc = {};
controller.responseActive = true;
controller.stop();
assert.match(ui.costValue.title, /Usage is incomplete/);
});
test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
const { controller, ui } = costControllerHarness();
controller.status = 'listening';
controller._cost.cloudVoiceAuth = 'oauth';
controller._cost.sessionCloudVoiceAuth = 'oauth';
controller._cost.bindTranscriptionModel('gpt-4o-mini-transcribe');
const before = controller.costTracker.state();
// Priced, this much caption audio would cross the $5 cap.
const result = controller.recordTranscriptionUsage({
type: 'duration',
seconds: 3_600_000,
});
assert.equal(result.captions, 1);
assert.equal(result.costKnown, false);
const after = controller.costTracker.state();
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(after.capReached, false);
assert.equal(controller.costCapStopped, false);
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
});
test('F1: when idle, toggling does re-price the preview meter', () => {
const { controller } = costControllerHarness();
controller.status = 'idle';
+10 -2
View File
@@ -15,11 +15,19 @@ export function createRealtimeBackend({
);
return Object.freeze({
protocol: 'openai-realtime',
async requestToken({ tier = DEFAULT_VOICE_TIER, signal } = {}) {
async requestToken({
tier = DEFAULT_VOICE_TIER,
authMode = 'api-key',
signal,
} = {}) {
signal = scoped(signal);
signal.throwIfAborted();
const separator = tokenEndpoint.includes('?') ? '&' : '?';
const url = `${tokenEndpoint}${separator}tier=${encodeURIComponent(resolveVoiceModel(tier).tier)}`;
const query = new URLSearchParams({
tier: resolveVoiceModel(tier).tier,
});
if (authMode === 'oauth') query.set('auth', 'oauth');
const url = `${tokenEndpoint}${separator}${query}`;
const response = await tokenTransport(url, {
signal,
cache: 'no-store',
+16
View File
@@ -41,6 +41,22 @@ test('separate configured token and SDP transports preserve model metadata and c
]);
});
test('OAuth is an additive token query while API key stays the default', async () => {
const calls = [];
const backend = createRealtimeBackend({
tokenTransport: async (input) => {
calls.push(input);
return tokenReply();
},
});
await backend.requestToken();
await backend.requestToken({ authMode: 'oauth' });
assert.deepEqual(calls, [
'/api/realtime/token?tier=standard',
'/api/realtime/token?tier=standard&auth=oauth',
]);
});
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
let requests = 0;
const backend = createRealtimeBackend({
+2
View File
@@ -82,6 +82,7 @@ export class RealtimeConnection {
this.debugLog('session.starting', {
epoch,
tier: this.cost.voiceTier,
authMode: this.cost.sessionCloudVoiceAuth,
connection: this.connectionDiagnostics(),
});
let localStream = null;
@@ -89,6 +90,7 @@ export class RealtimeConnection {
try {
const minted = await this.backend.requestToken({
tier: this.cost.voiceTier,
authMode: this.cost.sessionCloudVoiceAuth,
signal,
});
const token = minted.token;
+4 -1
View File
@@ -374,7 +374,10 @@ export class GevRealtimeController extends RealtimeFacade {
status: this.status,
connection: this.connectionDiagnostics(),
});
if (this.dc && this.responseActive) this.costTracker.markIncomplete();
if (this.dc && this.responseActive) {
this.costTracker.markIncomplete();
this.syncCostUi();
}
this._metrics.flush('stop');
this._connection.closeTransport();
this.stopVoiceVisualizer();
+38
View File
@@ -4,6 +4,7 @@ import {
writeStoredVoiceTier,
writeStoredVoiceLimits,
} from './realtimePreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import {
createVoiceCostTracker,
resolveVoiceModel,
@@ -17,6 +18,9 @@ export class RealtimeCost {
Object.assign(this, { readUi, readStatus }, operations);
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = null;
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 };
this.transcribeModel = null;
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
@@ -58,6 +62,21 @@ export class RealtimeCost {
}; applies next session`;
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
if (liveAuth === 'oauth') {
const { responses, input, output, captions } = this.oauthUsage;
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = 'COST UNKNOWN';
this.ui.costValue.dataset.level = 'unknown';
this.ui.costValue.title =
`ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens, ${captions} caption transcription(s) reported. ` +
'USD cost and the API spend cap are unavailable for this auth mode.' +
(state.incomplete
? ' Usage is incomplete because a response was still in flight when the session ended.'
: '');
return;
}
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
@@ -138,6 +157,15 @@ export class RealtimeCost {
*/
recordUsage(usage) {
if (!usage) return null;
if (this.sessionCloudVoiceAuth === 'oauth') {
const count = (value) =>
Number.isFinite(value) && value > 0 ? value : 0;
this.oauthUsage.responses += 1;
this.oauthUsage.input += count(usage.input_tokens);
this.oauthUsage.output += count(usage.output_tokens);
this.syncCostUi();
return { ...this.oauthUsage, costKnown: false };
}
return this.applyCost(this.costTracker.record(usage));
}
@@ -148,6 +176,13 @@ export class RealtimeCost {
*/
recordTranscriptionUsage(usage) {
if (!usage) return null;
// An OAuth session has no API-dollar meter, so captions are counted
// rather than priced into the API-key warning and cap.
if (this.sessionCloudVoiceAuth === 'oauth') {
this.oauthUsage.captions += 1;
this.syncCostUi();
return { ...this.oauthUsage, costKnown: false };
}
return this.applyCost(
this.costTracker.recordUsd(
estimateTranscriptionCostUsd(usage, this.transcribeModel),
@@ -213,6 +248,9 @@ export class RealtimeCost {
prepareSession() {
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 };
this.costCapStopped = false;
// Provisional meter (tier-priced) so the readout shows $0.00 while
// connecting. It is REPLACED below with one bound to the model the server