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:
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();
}