web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit 444e8ebea5ebb734bc43cb41e8faa3c9adf1ffc3
parent c0d7dc4d1d8891c5b3595f710b99fb5b20dae6af
Author: triesap <triesap@radroots.dev>
Date:   Fri, 21 Nov 2025 01:12:38 +0000

client: upgrade web geolocation with policy-aware permission checks and richer error mapping, and require an explicit sanitized api base url by dropping env-based defaults

Diffstat:
Dclient/.env.example | 2--
Dclient/src/_env.ts | 12------------
Mclient/src/geolocation/types.ts | 22+++++++++++++---------
Mclient/src/geolocation/web.ts | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mclient/src/radroots/web.ts | 9+++++----
5 files changed, 149 insertions(+), 34 deletions(-)

diff --git a/client/.env.example b/client/.env.example @@ -1 +0,0 @@ -VITE_PUBLIC_RADROOTS_API= -\ No newline at end of file diff --git a/client/src/_env.ts b/client/src/_env.ts @@ -1,12 +0,0 @@ -import dotenv from "dotenv"; -dotenv.config(); - -const RADROOTS_API = process.env.VITE_PUBLIC_RADROOTS_API; -if (!RADROOTS_API || typeof RADROOTS_API !== 'string') throw new Error('Missing env var: VITE_PUBLIC_RADROOTS_API'); - -const PROD = process.env.NODE_ENV === 'production'; - -export const _envLib = { - PROD, - RADROOTS_API, -} as const; diff --git a/client/src/geolocation/types.ts b/client/src/geolocation/types.ts @@ -1,11 +1,16 @@ import type { IClientGeolocationPosition, ResolveErrorMsg } from "@radroots/utils"; -export type IGeolocationIError = - | `error.client.geolocation.permission_denied` - | `error.client.geolocation.location_unavailable` - | `error.client.geolocation.timeout` - | `*`; +export type ClientGeolocationError = + | "error.client.geolocation.permission_denied" + | "error.client.geolocation.location_unavailable" + | "error.client.geolocation.position_unavailable" + | "error.client.geolocation.timeout" + | "error.client.geolocation.blocked_by_permissions_policy" + | "error.client.geolocation.unknown_error" + | "*"; -export type IClientGeolocation = { - current(): Promise<ResolveErrorMsg<IClientGeolocationPosition, IGeolocationIError>>; -}; -\ No newline at end of file +export type IGeolocationIError = ClientGeolocationError; + +export interface IClientGeolocation { + current(): Promise<ResolveErrorMsg<IClientGeolocationPosition, ClientGeolocationError>>; +} diff --git a/client/src/geolocation/web.ts b/client/src/geolocation/web.ts @@ -1,14 +1,124 @@ import { err_msg } from "@radroots/utils"; import type { IClientGeolocation } from "./types.js"; +type GeoPolicyAllows = boolean | "unknown"; +type GeoPermissionState = PermissionState | "unknown"; + +interface PermissionsPolicyLike { + allowsFeature(feature: "geolocation"): boolean; +} + +interface DocumentWithPermissionsPolicy extends Document { + permissionsPolicy: PermissionsPolicyLike; +} + +interface NavigatorWithPermissions extends Navigator { + permissions: Permissions; +} + +interface GeoDebug { + policy_allows: GeoPolicyAllows; + permission_state: GeoPermissionState; + error_code?: number; + error_message?: string; + user_agent: string; +} + +const geo_debug_enabled = true; + +function has_permissions_policy(doc: Document): doc is DocumentWithPermissionsPolicy { + return "permissionsPolicy" in doc; +} + +function has_permissions_api(nav: Navigator): nav is NavigatorWithPermissions { + return "permissions" in nav; +} + +function read_policy_allows_geolocation(doc: Document): GeoPolicyAllows { + if (!has_permissions_policy(doc)) return "unknown"; + try { + return doc.permissionsPolicy.allowsFeature("geolocation"); + } catch { + return "unknown"; + } +} + +async function read_permission_state_geolocation(nav: Navigator): Promise<GeoPermissionState> { + if (!has_permissions_api(nav)) return "unknown"; + try { + const status = await nav.permissions.query({ name: "geolocation" }); + return status.state; + } catch { + return "unknown"; + } +} + +function create_debug( + policy_allows: GeoPolicyAllows, + permission_state: GeoPermissionState +): GeoDebug { + return { + policy_allows, + permission_state, + user_agent: navigator.userAgent + }; +} + +function log_geo_debug(event: string, debug: GeoDebug): void { + if (!geo_debug_enabled) return; + console.debug(event, debug); +} + +function get_current_position(): Promise<GeolocationPosition> { + return new Promise<GeolocationPosition>((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 30000 + }); + }); +} + +function map_error_key( + debug: GeoDebug, + error: GeolocationPositionError +) { + if (error.code === 1) { + if (debug.policy_allows === false) { + return "error.client.geolocation.blocked_by_permissions_policy"; + } + return "error.client.geolocation.permission_denied"; + } + + if (error.code === 2) { + return "error.client.geolocation.position_unavailable"; + } + + if (error.code === 3) { + return "error.client.geolocation.timeout"; + } + + return "error.client.geolocation.unknown_error"; +} + export class WebGeolocation implements IClientGeolocation { public async current() { - try { - if (!navigator.geolocation) return err_msg("error.client.geolocation.location_unavailable"); + if (!navigator.geolocation) { + return err_msg("error.client.geolocation.location_unavailable"); + } + + const policy_allows = read_policy_allows_geolocation(document); + const permission_state = await read_permission_state_geolocation(navigator); + + const base_debug = create_debug(policy_allows, permission_state); + + if (policy_allows === false) { + log_geo_debug("[geolocation] blocked_by_policy", base_debug); + return err_msg("error.client.geolocation.blocked_by_permissions_policy"); + } - const position = await new Promise<GeolocationPosition>((resolve, reject) => - navigator.geolocation.getCurrentPosition(resolve, reject) - ); + try { + const position = await get_current_position(); return { lat: position.coords.latitude, @@ -16,7 +126,21 @@ export class WebGeolocation implements IClientGeolocation { accuracy: position.coords.accuracy }; } catch (e) { - return err_msg("*"); + if (e instanceof GeolocationPositionError) { + const debug: GeoDebug = { + ...base_debug, + error_code: e.code, + error_message: e.message + }; + + const key = map_error_key(debug, e); + log_geo_debug("[geolocation] error", debug); + + return err_msg(key); + } + + log_geo_debug("[geolocation] unknown_exception", base_debug); + return err_msg("error.client.geolocation.unknown_error"); } - }; + } } diff --git a/client/src/radroots/web.ts b/client/src/radroots/web.ts @@ -1,6 +1,5 @@ import { err_msg, type IHttpResponse, is_err_response, is_error_response } from '@radroots/utils'; import { lib_nostr_event_sign_attest } from '@radroots/utils-nostr'; -import { _envLib } from '../_env.js'; import { WebHttp } from '../http/web.js'; import type { IClientRadroots, IClientRadrootsAccountsActivate, IClientRadrootsAccountsActivateResolve, IClientRadrootsAccountsCreate, IClientRadrootsAccountsCreateResolve, IClientRadrootsAccountsRequest, IClientRadrootsAccountsRequestResolve, IClientRadrootsMediaImageUpload, IClientRadrootsMediaImageUploadResolve } from "./types.js"; @@ -8,9 +7,11 @@ export class WebClientRadroots implements IClientRadroots { private _base_url: string private _http_client: WebHttp - constructor(base_url?: string) { - const url = base_url || _envLib.RADROOTS_API; - this._base_url = url.replaceAll(`/`, ``); + constructor(base_url: string) { + if (!base_url) throw new Error(`Missing base_url`); + const parsed_url = new URL(base_url); + const sanitized_base_url = `${parsed_url.origin}${parsed_url.pathname}`.replace(/\/+$/, ``); + this._base_url = sanitized_base_url; this._http_client = new WebHttp(); }