web_lib

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

commit 58c9dbf069d658dd642a083a4a2644e9028ab90c
parent efa8d40abf6de154651d9f3fbd0e4abae8703759
Author: triesap <triesap@radroots.dev>
Date:   Fri, 26 Dec 2025 00:15:32 +0000

web: align apps-lib and apps-lib-pwa modules

- Flatten apps-lib public API via barrel exports (components/stores/styles/types/utils)
- Rename shared symbols/config/style maps to UPPER_SNAKE_CASE exports
- Type lib-pwa get_context calls with LibContext and move context defs to types/
- Add browser-safe idb keyval wrapper and guard sync paths in pwa inputs/selects

Diffstat:
Mapps-lib-market/svelte.config.js | 8++++----
Mapps-lib-market/vite.config.ts | 2+-
Mapps-lib-pwa/src/lib/components/button/button-glyph-circle.svelte | 6+++---
Mapps-lib-pwa/src/lib/components/button/button-glyph.svelte | 4++--
Mapps-lib-pwa/src/lib/components/farm/farms-add-detail.svelte | 3++-
Mapps-lib-pwa/src/lib/components/farm/farms-add-map.svelte | 3++-
Mapps-lib-pwa/src/lib/components/farm/farms-preview-card.svelte | 3++-
Mapps-lib-pwa/src/lib/components/form/form-line-ledger-label-select-label.svelte | 6+++---
Mapps-lib-pwa/src/lib/components/lib/input-pwa.svelte | 8++++----
Mapps-lib-pwa/src/lib/components/lib/load-circle.svelte | 6+++---
Mapps-lib-pwa/src/lib/components/lib/load-symbol.svelte | 6+++---
Mapps-lib-pwa/src/lib/components/lib/select-pwa.svelte | 8++++----
Mapps-lib-pwa/src/lib/components/map/map-marker-area.svelte | 3++-
Mapps-lib-pwa/src/lib/components/map/map.svelte | 4++--
Aapps-lib-pwa/src/lib/components/media/image-path.svelte | 22++++++++++++++++++++++
Mapps-lib-pwa/src/lib/components/media/image-upload-photo-add.svelte | 3++-
Mapps-lib-pwa/src/lib/components/trellis/trellis.svelte | 3++-
Mapps-lib-pwa/src/lib/index.ts | 1+
Aapps-lib-pwa/src/lib/types/context.ts | 20++++++++++++++++++++
Rapps-lib/src/lib/utils/_env.ts -> apps-lib-pwa/src/lib/utils/_env.ts | 0
Mapps-lib-pwa/src/lib/utils/app.ts | 5++---
Dapps-lib-pwa/src/lib/utils/context.ts | 22----------------------
Aapps-lib-pwa/src/lib/utils/keyval.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapps-lib-pwa/src/lib/utils/map.ts | 5++---
Rapps-lib-pwa/src/lib/utils/profile/lib.ts -> apps-lib-pwa/src/lib/utils/profile.ts | 0
Mapps-lib-pwa/src/lib/utils/styles.ts | 2+-
Mapps-lib-pwa/src/lib/views/farms/farms-add.svelte | 3++-
Mapps-lib-pwa/src/lib/views/farms/farms.svelte | 5+++--
Mapps-lib-pwa/src/lib/views/profile/profile-edit.svelte | 14+++++++-------
Mapps-lib-pwa/src/lib/views/profile/profile.svelte | 12++++++------
Mapps-lib-pwa/src/lib/views/root/home.svelte | 6++++--
Mapps-lib-pwa/src/lib/views/root/settings.svelte | 9+++++----
Mapps-lib-pwa/svelte.config.js | 8++++----
Mapps-lib-pwa/vite.config.ts | 2+-
Mapps-lib/src/global.d.ts | 14++++++++++++++
Mapps-lib/src/lib/components/fade.svelte | 2+-
Mapps-lib/src/lib/components/glyph.svelte | 6+++---
Mapps-lib/src/lib/components/glyphi.svelte | 6+++---
Mapps-lib/src/lib/components/image-blob.svelte | 31++++++++++++++++---------------
Dapps-lib/src/lib/components/image-path.svelte | 23-----------------------
Mapps-lib/src/lib/components/image-src.svelte | 2+-
Aapps-lib/src/lib/components/index.ts | 7+++++++
Dapps-lib/src/lib/components/input-ext.svelte | 117-------------------------------------------------------------------------------
Mapps-lib/src/lib/index.ts | 30+++++-------------------------
Mapps-lib/src/lib/stores/carousel.ts | 2+-
Aapps-lib/src/lib/stores/index.ts | 3+++
Mapps-lib/src/lib/stores/theme.ts | 2+-
Mapps-lib/src/lib/styles/glyphs.ts | 5++---
Aapps-lib/src/lib/styles/index.ts | 1+
Mapps-lib/src/lib/types/components.ts | 1-
Aapps-lib/src/lib/types/index.ts | 3+++
Mapps-lib/src/lib/utils/app/carousel.ts | 3+--
Aapps-lib/src/lib/utils/app/index.ts | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapps-lib/src/lib/utils/app/lib.ts | 234-------------------------------------------------------------------------------
Mapps-lib/src/lib/utils/browser.ts | 31++++++++++++++-----------------
Aapps-lib/src/lib/utils/fetch.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Dapps-lib/src/lib/utils/fetch/lib.ts | 8--------
Mapps-lib/src/lib/utils/i18n.ts | 17+++++++++--------
Aapps-lib/src/lib/utils/index.ts | 7+++++++
Mapps-lib/src/lib/utils/keyval/idb.ts | 144+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Dapps-lib/src/lib/utils/keyval/lib.ts | 53-----------------------------------------------------
Mapps-lib/svelte.config.js | 8++++----
Mapps-lib/vite.config.ts | 2+-
Mclient/src/backup/index.ts | 10++++------
Mclient/src/cipher/types.ts | 20++++++++++++++++----
Mclient/src/cipher/web.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mclient/src/crypto/envelope.ts | 16++++++++--------
Mclient/src/crypto/provider.ts | 5++++-
Mclient/src/crypto/registry.ts | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mclient/src/crypto/service.ts | 205+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mclient/src/crypto/types.ts | 14+++++++-------
Mclient/src/datastore/web.ts | 182++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Aclient/src/idb/encrypted_store.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mclient/src/idb/store.ts | 46+++++++++++++++++++++++++++++++++++++++++-----
Aclient/src/idb/value.ts | 6++++++
Mclient/src/keystore/index.ts | 2--
Mclient/src/keystore/web.ts | 73+++++++++++++++++++++++++++++++++----------------------------------------
Mclient/src/sql/error.ts | 1+
Mclient/src/sql/web.ts | 65++++++++++++++++++++++++++++++-----------------------------------
Mclient/src/tangle/types.ts | 3++-
Mclient/src/tangle/web.ts | 35++++++++++++++++++-----------------
Aclient/src/utils/resolve.ts | 5+++++
82 files changed, 1334 insertions(+), 1044 deletions(-)

diff --git a/apps-lib-market/svelte.config.js b/apps-lib-market/svelte.config.js @@ -2,10 +2,10 @@ import adapter from '@sveltejs/adapter-auto'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter(), - }, + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, }; export default config; diff --git a/apps-lib-market/vite.config.ts b/apps-lib-market/vite.config.ts @@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit() as any] + plugins: [sveltekit()] }); diff --git a/apps-lib-pwa/src/lib/components/button/button-glyph-circle.svelte b/apps-lib-pwa/src/lib/components/button/button-glyph-circle.svelte @@ -1,14 +1,14 @@ <script lang="ts"> import type { IButtonGlyphCircle } from "$lib/types/components/lib"; - import { fmt_cl, glyph_style_map } from "@radroots/apps-lib"; + import { fmt_cl, GLYPH_STYLE_MAP } from "@radroots/apps-lib"; import GlyphButton from "./button-glyph.svelte"; let { basis }: { basis: IButtonGlyphCircle } = $props(); const styles = $derived( basis?.glyph?.dim - ? glyph_style_map.get(basis?.glyph?.dim) - : glyph_style_map.get(`sm`), + ? GLYPH_STYLE_MAP.get(basis?.glyph?.dim) + : GLYPH_STYLE_MAP.get(`sm`), ); </script> diff --git a/apps-lib-pwa/src/lib/components/button/button-glyph.svelte b/apps-lib-pwa/src/lib/components/button/button-glyph.svelte @@ -1,9 +1,9 @@ <script lang="ts"> - import { type IGlyph, fmt_cl, glyph_style_map } from "@radroots/apps-lib"; + import { type IGlyph, fmt_cl, GLYPH_STYLE_MAP } from "@radroots/apps-lib"; let { basis }: { basis: IGlyph } = $props(); const styles = $derived( - basis?.dim ? glyph_style_map.get(basis.dim) : glyph_style_map.get(`sm`), + basis?.dim ? GLYPH_STYLE_MAP.get(basis.dim) : GLYPH_STYLE_MAP.get(`sm`), ); </script> diff --git a/apps-lib-pwa/src/lib/components/farm/farms-add-detail.svelte b/apps-lib-pwa/src/lib/components/farm/farms-add-detail.svelte @@ -1,9 +1,10 @@ <script lang="ts"> import FormLineLedger from "$lib/components/form/form-line-ledger.svelte"; + import type { LibContext } from "$lib/types/context"; import { get_context } from "@radroots/apps-lib"; import { area_units, form_fields } from "@radroots/utils"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { val_farmname = $bindable(``), diff --git a/apps-lib-pwa/src/lib/components/farm/farms-add-map.svelte b/apps-lib-pwa/src/lib/components/farm/farms-add-map.svelte @@ -3,6 +3,7 @@ import MapMarkerArea from "$lib/components/map/map-marker-area.svelte"; import Map from "$lib/components/map/map.svelte"; import { app_lo } from "$lib/stores/app"; + import type { LibContext } from "$lib/types/context"; import { focus_map_marker } from "$lib/utils/map"; import { Fade, geop_is_valid, get_context } from "@radroots/apps-lib"; import { @@ -12,7 +13,7 @@ import { handle_err } from "@radroots/utils"; import { onMount } from "svelte"; - const { lc_geop_current, lc_geocode } = get_context(`lib`); + const { lc_geop_current, lc_geocode } = get_context<LibContext>(`lib`); let { map_geoc = $bindable(undefined), diff --git a/apps-lib-pwa/src/lib/components/farm/farms-preview-card.svelte b/apps-lib-pwa/src/lib/components/farm/farms-preview-card.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import MapMarkerArea from "$lib/components/map/map-marker-area.svelte"; import Map from "$lib/components/map/map.svelte"; + import type { LibContext } from "$lib/types/context"; import type { FarmExtended } from "$lib/types/views/farms"; import { get_context } from "@radroots/apps-lib"; import { @@ -14,7 +15,7 @@ import type { CallbackPromiseGeneric } from "@radroots/utils"; import { onMount } from "svelte"; - const { ls, locale } = get_context(`lib`); + const { ls, locale } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/components/form/form-line-ledger-label-select-label.svelte b/apps-lib-pwa/src/lib/components/form/form-line-ledger-label-select-label.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { symbols } from "@radroots/apps-lib"; + import { SYMBOLS } from "@radroots/apps-lib"; let { basis, @@ -22,12 +22,12 @@ <p class={`absolute font-sansd text-trellis_ti text-ly0-gl-label uppercase scale-y-[70%] scale-x-[80%] -translate-y-[1px]`} > - {`${symbols.up}`} + {`${SYMBOLS.up}`} </p> <p class={`absolute font-sansd text-trellis_ti text-ly0-gl-label uppercase scale-y-[70%] scale-x-[80%] translate-y-[2px]`} > - {`${symbols.down}`} + {`${SYMBOLS.down}`} </p> </div> <p class={`font-sansd text-trellis_ti text-ly0-gl-label uppercase`}> diff --git a/apps-lib-pwa/src/lib/components/lib/input-pwa.svelte b/apps-lib-pwa/src/lib/components/lib/input-pwa.svelte @@ -1,8 +1,8 @@ <script lang="ts"> import { browser } from "$app/environment"; + import { idb_kv } from "$lib/utils/keyval"; import { fmt_cl, - idb_kv, type IInput, parse_layer, value_constrain, @@ -31,9 +31,9 @@ ); const sync_from_idb = async (): Promise<void> => { - if (!browser || !id) return; + if (!browser || !id || !idb_kv) return; try { - const kv_val = await idb_kv.get(id); + const kv_val = await idb_kv.get<string | undefined>(id); if (kv_val !== null && kv_val !== undefined && kv_val !== value) { value = kv_val; } else if (kv_val === null || kv_val === undefined) { @@ -46,7 +46,7 @@ }; const sync_to_idb = async (): Promise<void> => { - if (!browser || !id) return; + if (!browser || !id || !idb_kv) return; try { await idb_kv.set(id, value || ``); } catch (e) { diff --git a/apps-lib-pwa/src/lib/components/lib/load-circle.svelte b/apps-lib-pwa/src/lib/components/lib/load-circle.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { ILoadCircle } from "$lib/types/components/lib"; - import { loading_style_map } from "$lib/utils/styles"; + import { LOADING_STYLE_MAP } from "$lib/utils/styles"; import { fmt_cl, Glyph } from "@radroots/apps-lib"; let { @@ -11,8 +11,8 @@ const styles = $derived( basis?.dim - ? loading_style_map.get(basis?.dim) - : loading_style_map.get("sm"), + ? LOADING_STYLE_MAP.get(basis?.dim) + : LOADING_STYLE_MAP.get("sm"), ); </script> diff --git a/apps-lib-pwa/src/lib/components/lib/load-symbol.svelte b/apps-lib-pwa/src/lib/components/lib/load-symbol.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { loading_style_map } from "$lib/utils/styles"; + import { LOADING_STYLE_MAP } from "$lib/utils/styles"; import type { ILoadSymbol } from "@radroots/apps-lib"; let { @@ -10,8 +10,8 @@ const styles = $derived( basis?.dim - ? loading_style_map.get(basis?.dim) - : loading_style_map.get("sm"), + ? LOADING_STYLE_MAP.get(basis?.dim) + : LOADING_STYLE_MAP.get("sm"), ); const num_blades = $derived(basis?.blades || 8); diff --git a/apps-lib-pwa/src/lib/components/lib/select-pwa.svelte b/apps-lib-pwa/src/lib/components/lib/select-pwa.svelte @@ -1,10 +1,10 @@ <script lang="ts"> import { browser } from "$app/environment"; + import { idb_kv } from "$lib/utils/keyval"; import { Glyph, type ISelect, fmt_cl, - idb_kv, parse_layer, } from "@radroots/apps-lib"; import { handle_err } from "@radroots/utils"; @@ -38,7 +38,7 @@ onMount(async () => { try { - if (id && basis?.sync_init && browser) { + if (id && basis?.sync_init && browser && idb_kv) { const sync_val = await idb_kv.get(id); await idb_kv.set(id, sync_val || ``); } @@ -48,7 +48,7 @@ }); $effect(() => { - if (browser && id && basis?.sync) { + if (browser && id && basis?.sync && idb_kv) { (async () => { await idb_kv.set(id, value); })(); @@ -62,7 +62,7 @@ .reduce((_, j) => j, []) .find((k) => k.value === el?.value); if (el) el.value = value; - if (basis?.sync && id && browser) await idb_kv.set(id, value); + if (basis?.sync && id && browser && idb_kv) await idb_kv.set(id, value); if (basis.callback && opt) await basis.callback(opt); } catch (e) { console.log(`(error) handle_on_change `, e); diff --git a/apps-lib-pwa/src/lib/components/map/map-marker-area.svelte b/apps-lib-pwa/src/lib/components/map/map-marker-area.svelte @@ -1,4 +1,5 @@ <script lang="ts"> + import type { LibContext } from "$lib/types/context"; import type { IMapMarkerArea } from "$lib/types/components/lib"; import { get_context } from "@radroots/apps-lib"; import { @@ -8,7 +9,7 @@ import { Marker, Popup } from "svelte-maplibre"; import MapMarkerAreaDisplay from "./map-marker-area-display.svelte"; - const { lc_geocode } = get_context(`lib`); + const { lc_geocode } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/components/map/map.svelte b/apps-lib-pwa/src/lib/components/map/map.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { cfg_map } from "$lib/utils/map"; + import { CFG_MAP } from "$lib/utils/map"; import { type IClOpt, fmt_cl, theme_mode } from "@radroots/apps-lib"; import type { Snippet } from "svelte"; import { MapLibre } from "svelte-maplibre"; @@ -33,7 +33,7 @@ bind:map class="{fmt_cl(basis?.classes)} relative h-full w-full" zoom={10} - style={cfg_map.styles.base[$theme_mode ?? "light"]} + style={CFG_MAP.styles.base[$theme_mode ?? "light"]} attributionControl={false} interactive={!!interactive} zoomOnDoubleClick={double_click_zoom} diff --git a/apps-lib-pwa/src/lib/components/media/image-path.svelte b/apps-lib-pwa/src/lib/components/media/image-path.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { get_context, ImageBlob, ImageSrc } from "@radroots/apps-lib"; + import type { IImagePath } from "@radroots/apps-lib"; + import type { LibContext } from "$lib/types/context"; + + const { lc_img_bin } = get_context<LibContext>(`lib`); + let { basis }: { basis: IImagePath } = $props(); + + const img_path = $derived(basis.path); +</script> + +{#if img_path} + {@const is_bin = img_path.startsWith(`file:`)} + + {#if is_bin} + {#await lc_img_bin(img_path) then data} + <ImageBlob basis={{ data, ...basis }} /> + {/await} + {:else} + <ImageSrc basis={{ src: img_path, ...basis }} /> + {/if} +{/if} diff --git a/apps-lib-pwa/src/lib/components/media/image-upload-photo-add.svelte b/apps-lib-pwa/src/lib/components/media/image-upload-photo-add.svelte @@ -1,8 +1,9 @@ <script lang="ts"> + import type { LibContext } from "$lib/types/context"; import { get_context, Glyph } from "@radroots/apps-lib"; import LoadSymbol from "../lib/load-symbol.svelte"; - const { ls, lc_photos_add } = get_context(`lib`); + const { ls, lc_photos_add } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/components/trellis/trellis.svelte b/apps-lib-pwa/src/lib/components/trellis/trellis.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { app_lo } from "$lib/stores/app"; + import type { LibContext } from "$lib/types/context"; import type { ITrellis } from "$lib/types/components/trellis"; import { fmt_cl, get_context, parse_layer } from "@radroots/apps-lib"; import type { Snippet } from "svelte"; @@ -10,7 +11,7 @@ import TrellisTitle from "./trellis-title.svelte"; import TrellisTouch from "./trellis-touch.svelte"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/index.ts b/apps-lib-pwa/src/lib/index.ts @@ -37,6 +37,7 @@ export { default as WrapBorder } from "./components/lib/wrap-border.svelte"; export { default as MapMarkerAreaDisplay } from "./components/map/map-marker-area-display.svelte"; export { default as MapMarkerArea } from "./components/map/map-marker-area.svelte"; export { default as Map } from "./components/map/map.svelte"; +export { default as ImagePath } from "./components/media/image-path.svelte"; export { default as ImageUploadPhotoAdd } from "./components/media/image-upload-photo-add.svelte"; export { default as NavigationTabs } from "./components/navigation/navigation-tabs.svelte"; export { default as PageHeader } from "./components/navigation/page-header.svelte"; diff --git a/apps-lib-pwa/src/lib/types/context.ts b/apps-lib-pwa/src/lib/types/context.ts @@ -0,0 +1,20 @@ +import type { I18nTranslateFunction, I18nTranslateLocale, LocalCallbackColorMode, LocalCallbackGeocode, LocalCallbackGeocodeCurrent, LocalCallbackGuiAlert, LocalCallbackGuiConfirm, LocalCallbackImgBin, LocalCallbackPhotosAddMultiple, LocalCallbackPhotosUpload } from "@radroots/apps-lib"; + +export type ContextKeys = `lib`; + +export type ContextMap = { + lib: LibContext; +}; + +export type LibContext = { + ls: I18nTranslateFunction; + locale: I18nTranslateLocale; + lc_color_mode: LocalCallbackColorMode; + lc_gui_alert: LocalCallbackGuiAlert; + lc_gui_confirm: LocalCallbackGuiConfirm; + lc_geocode: LocalCallbackGeocode; + lc_photos_add: LocalCallbackPhotosAddMultiple; + lc_img_bin: LocalCallbackImgBin; + lc_geop_current: LocalCallbackGeocodeCurrent; + lc_photos_upload: LocalCallbackPhotosUpload; +}; diff --git a/apps-lib/src/lib/utils/_env.ts b/apps-lib-pwa/src/lib/utils/_env.ts diff --git a/apps-lib-pwa/src/lib/utils/app.ts b/apps-lib-pwa/src/lib/utils/app.ts @@ -10,7 +10,7 @@ type ConfigWindow = { } }; -export const cfg_app: ConfigWindow = { +export const CFG_APP: ConfigWindow = { layout: { ios0: { h: 600 @@ -36,4 +36,4 @@ export const fmt_trellis = (hide_border_t: boolean, hide_border_b: boolean): str export const get_label_classes_kind = (layer: ThemeLayer, label_kind: LabelFieldKind | undefined, hide_active: boolean): string => { return `text-ly${layer}-gl${label_kind ? `-${label_kind}` : ``} ${hide_active ? `` : `group-active:text-ly${layer}-gl${label_kind ? `-${label_kind}_a` : `_a`}`}` -}; -\ No newline at end of file +}; diff --git a/apps-lib-pwa/src/lib/utils/context.ts b/apps-lib-pwa/src/lib/utils/context.ts @@ -1,21 +0,0 @@ -import type { I18nTranslateFunction, I18nTranslateLocale, LocalCallbackColorMode, LocalCallbackGeocode, LocalCallbackGeocodeCurrent, LocalCallbackGuiAlert, LocalCallbackGuiConfirm, LocalCallbackImgBin, LocalCallbackPhotosAddMultiple, LocalCallbackPhotosUpload } from "@radroots/apps-lib"; - -export type ContextKeys = - | `lib`; - -export type ContextMap = { - lib: LibContext; -}; - -export type LibContext = { - ls: I18nTranslateFunction; - locale: I18nTranslateLocale; - lc_color_mode: LocalCallbackColorMode; - lc_gui_alert: LocalCallbackGuiAlert; - lc_gui_confirm: LocalCallbackGuiConfirm; - lc_geocode: LocalCallbackGeocode; - lc_photos_add: LocalCallbackPhotosAddMultiple; - lc_img_bin: LocalCallbackImgBin; - lc_geop_current: LocalCallbackGeocodeCurrent; - lc_photos_upload: LocalCallbackPhotosUpload; -}; -\ No newline at end of file diff --git a/apps-lib-pwa/src/lib/utils/keyval.ts b/apps-lib-pwa/src/lib/utils/keyval.ts @@ -0,0 +1,62 @@ +import { browser } from "$app/environment"; +import { fmt_id, IdbKeyval } from "@radroots/apps-lib"; +import { _env_lib } from "$lib/utils/_env"; + +export let idb_kv: IdbKeyval | undefined; +if (browser) idb_kv = new IdbKeyval({ name: _env_lib.KEYVAL_NAME }); + +export const idb_kv_init = async (): Promise<void> => { + if (!browser || !idb_kv) return; + const kv = idb_kv; + const range = IdbKeyval.prefix(`*`); + const idb_kv_list = await kv.each({ range }, `keys`); + await Promise.all(idb_kv_list.map((i) => kv.delete(i))); +}; + +export const idb_kv_init_page = async (): Promise<void> => { + if (!browser || !idb_kv) return; + const kv = idb_kv; + const idb_kv_pref = fmt_id(); + const range = IdbKeyval.prefix(idb_kv_pref); + const idb_kv_list = await kv.each({ range }, `keys`); + await Promise.all(idb_kv_list.map((i) => kv.delete(i))); +}; + +export const idb_kv_sync = async (list: [string, string][]): Promise<void> => { + if (!browser || !idb_kv) return; + const kv = idb_kv; + for (const [key, val] of list) await kv.set(key, val); +}; + +export interface IIdbLib<T extends string> { + init: () => Promise<void>; + save: (key: T, value: string) => Promise<void>; + read: (key: T) => Promise<string | undefined>; + del: (key: T) => Promise<void>; +} + +export class IdbLib<T extends string> implements IIdbLib<T> { + private _idb: IdbKeyval; + + constructor(kv: IdbKeyval) { + this._idb = kv; + } + + public init = async (): Promise<void> => { + await idb_kv_init_page(); + }; + + public save = async (key: T, value: string): Promise<void> => { + await this._idb.set(fmt_id(key), value); + }; + + public read = async (key: T): Promise<string | undefined> => { + const result = await this._idb.get<string>(fmt_id(key)); + if (result) return result; + return undefined; + }; + + public del = async (key: T): Promise<void> => { + await this._idb.delete(fmt_id(key)); + }; +} diff --git a/apps-lib-pwa/src/lib/utils/map.ts b/apps-lib-pwa/src/lib/utils/map.ts @@ -1,4 +1,4 @@ -export const cfg_map = { +export const CFG_MAP = { styles: { base: { light: `https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json`, @@ -21,4 +21,4 @@ export const cfg_map = { export const focus_map_marker = (): void => { const el = document.querySelector(".maplibregl-marker"); if (el instanceof HTMLElement) el.click(); -}; -\ No newline at end of file +}; diff --git a/apps-lib-pwa/src/lib/utils/profile/lib.ts b/apps-lib-pwa/src/lib/utils/profile.ts diff --git a/apps-lib-pwa/src/lib/utils/styles.ts b/apps-lib-pwa/src/lib/utils/styles.ts @@ -1,6 +1,6 @@ import type { LoadingDimension } from "@radroots/apps-lib"; -export const loading_style_map: Map<LoadingDimension, { dim_1: number; gl_2: number }> = new Map([ +export const LOADING_STYLE_MAP: Map<LoadingDimension, { dim_1: number; gl_2: number }> = new Map([ ["glyph-send-button", { dim_1: 20, gl_2: 20 }], ["xs", { dim_1: 12, gl_2: 12 }], ["sm", { dim_1: 16, gl_2: 16 }], diff --git a/apps-lib-pwa/src/lib/views/farms/farms-add.svelte b/apps-lib-pwa/src/lib/views/farms/farms-add.svelte @@ -8,6 +8,7 @@ import CarouselItem from "$lib/components/lib/carousel-item.svelte"; import PageToolbar from "$lib/components/navigation/page-toolbar.svelte"; import { app_platform } from "$lib/stores/app"; + import type { LibContext } from "$lib/types/context"; import type { IViewFarmsAddSubmission } from "$lib/types/views/farms"; import { schema_view_farms_add_submission } from "$lib/utils/farm/schema"; import { focus_map_marker } from "$lib/utils/map"; @@ -39,7 +40,7 @@ import { onMount } from "svelte"; const { ls, locale, lc_gui_alert, lc_geop_current, lc_geocode } = - get_context(`lib`); + get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/views/farms/farms.svelte b/apps-lib-pwa/src/lib/views/farms/farms.svelte @@ -5,12 +5,13 @@ import LayoutPage from "$lib/components/layout/layout-page.svelte"; import LayoutView from "$lib/components/layout/layout-view.svelte"; import PageToolbar from "$lib/components/navigation/page-toolbar.svelte"; + import type { LibContext } from "$lib/types/context"; import type { IViewBasis } from "$lib/types/views"; import type { IViewFarmsData } from "$lib/types/views/farms"; + import { idb_kv_init_page } from "$lib/utils/keyval"; import { Fade, get_context, - idb_kv_init_page, type CallbackRoute, } from "@radroots/apps-lib"; import { @@ -20,7 +21,7 @@ } from "@radroots/utils"; import { onMount } from "svelte"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/views/profile/profile-edit.svelte b/apps-lib-pwa/src/lib/views/profile/profile-edit.svelte @@ -1,23 +1,23 @@ <script lang="ts"> - import { FloatPage, LayoutPage, LayoutView, NavigationTabs } from "$lib"; + import { FloatPage, InputPwa, LayoutPage, LayoutView, NavigationTabs } from "$lib"; import ButtonRoundNav from "$lib/components/button/button-round-nav.svelte"; + import type { LibContext } from "$lib/types/context"; import type { IViewBasis } from "$lib/types/views"; import type { IViewProfileEditData, ViewProfileEditFieldKey, } from "$lib/types/views/profile"; + import { idb_kv_init_page } from "$lib/utils/keyval"; import { type ElementCallbackValue, Flex, - InputExt, fmt_id, get_context, - idb_kv_init_page, } from "@radroots/apps-lib"; import { type CallbackPromiseGeneric, handle_err } from "@radroots/utils"; import { onMount } from "svelte"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { basis, @@ -34,7 +34,7 @@ val_field: string; } = $props(); - const param: Record<ViewProfileEditFieldKey, { placeholder: string }> = { + const PARAM: Record<ViewProfileEditFieldKey, { placeholder: string }> = { name: { placeholder: `${$ls(`icu.enter_*`, { value: `profile username` })}`, // @todo }, @@ -57,7 +57,7 @@ }); const input_placeholder = $derived( - basis.data?.field ? param[basis.data.field]?.placeholder : ``, + basis.data?.field ? PARAM[basis.data.field]?.placeholder : ``, ); </script> @@ -69,7 +69,7 @@ <Flex /> </div> {#if basis.data.field} - <InputExt + <InputPwa bind:value={val_field} basis={{ id: fmt_id(`field`), diff --git a/apps-lib-pwa/src/lib/views/profile/profile.svelte b/apps-lib-pwa/src/lib/views/profile/profile.svelte @@ -1,19 +1,19 @@ <script lang="ts"> - import { NavigationTabs, SelectMenu } from "$lib"; + import { ImagePath, NavigationTabs, SelectMenu } from "$lib"; import ButtonRoundNav from "$lib/components/button/button-round-nav.svelte"; import FloatPage from "$lib/components/lib/float-page.svelte"; import ImageUploadPhotoAdd from "$lib/components/media/image-upload-photo-add.svelte"; + import type { LibContext } from "$lib/types/context"; import type { IViewBasis } from "$lib/types/views"; import type { IViewProfileData, ViewProfileEditFieldKey, } from "$lib/types/views/profile"; + import { idb_kv_init_page } from "$lib/utils/keyval"; import { get_context, Glyph, - idb_kv_init_page, - ImagePath, - symbols, + SYMBOLS, type IViewOnDestroy, } from "@radroots/apps-lib"; import { @@ -23,7 +23,7 @@ } from "@radroots/utils"; import { onDestroy, onMount } from "svelte"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { basis, @@ -235,7 +235,7 @@ <p class={`font-sans font-[400] ${classes_photo_overlay_glyph}`} > - {symbols.bullet} + {SYMBOLS.bullet} </p> <button class={`flex flex-row justify-center items-center`} diff --git a/apps-lib-pwa/src/lib/views/root/home.svelte b/apps-lib-pwa/src/lib/views/root/home.svelte @@ -4,13 +4,15 @@ import LayoutView from "$lib/components/layout/layout-view.svelte"; import NavigationTabs from "$lib/components/navigation/navigation-tabs.svelte"; import PageToolbar from "$lib/components/navigation/page-toolbar.svelte"; + import type { LibContext } from "$lib/types/context"; import type { IViewBasis, IViewHomeData } from "$lib/types/views"; - import { get_context, idb_kv_init_page } from "@radroots/apps-lib"; + import { idb_kv_init_page } from "$lib/utils/keyval"; + import { get_context } from "@radroots/apps-lib"; import { handle_err, type CallbackPromise } from "@radroots/utils"; import { onMount } from "svelte"; - const { ls } = get_context(`lib`); + const { ls } = get_context<LibContext>(`lib`); let { basis, diff --git a/apps-lib-pwa/src/lib/views/root/settings.svelte b/apps-lib-pwa/src/lib/views/root/settings.svelte @@ -2,18 +2,19 @@ import { LayoutView, PageToolbar } from "$lib"; import LayoutTrellis from "$lib/components/layout/layout-trellis.svelte"; import Trellis from "$lib/components/trellis/trellis.svelte"; + import type { LibContext } from "$lib/types/context"; import type { ITrellisExtList } from "$lib/types/components/trellis"; import type { IViewBasis } from "$lib/types/views"; + import { idb_kv_init_page } from "$lib/utils/keyval"; import { get_context, - idb_kv_init_page, - symbols, + SYMBOLS, theme_mode, } from "@radroots/apps-lib"; import { handle_err } from "@radroots/utils"; import { onMount } from "svelte"; - const { ls, lc_color_mode } = get_context(`lib`); + const { ls, lc_color_mode } = get_context<LibContext>(`lib`); let { basis, @@ -71,7 +72,7 @@ { entries: [ { - value: symbols.bullet, + value: SYMBOLS.bullet, label: `${$ls(`icu.choose_*`, { value: `${$ls( `common.color_mode`, diff --git a/apps-lib-pwa/svelte.config.js b/apps-lib-pwa/svelte.config.js @@ -2,10 +2,10 @@ import adapter from '@sveltejs/adapter-auto'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter(), - }, + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, }; export default config; diff --git a/apps-lib-pwa/vite.config.ts b/apps-lib-pwa/vite.config.ts @@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit() as any] + plugins: [sveltekit()] }); diff --git a/apps-lib/src/global.d.ts b/apps-lib/src/global.d.ts @@ -1,3 +1,17 @@ declare module "$app/environment"; declare module "$app/navigation"; declare module "$app/stores"; + +type NavigatorUserAgentBrand = { + brand: string; + version: string; +}; + +type NavigatorUserAgentData = { + platform: string; + brands?: NavigatorUserAgentBrand[]; +}; + +interface Navigator { + userAgentData?: NavigatorUserAgentData; +} diff --git a/apps-lib/src/lib/components/fade.svelte b/apps-lib/src/lib/components/fade.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { fmt_cl } from "$lib/utils/app/lib"; + import { fmt_cl } from "$lib/utils/app"; import type { Snippet } from "svelte"; import { cubicIn, cubicOut } from "svelte/easing"; import { fade, type FadeParams } from "svelte/transition"; diff --git a/apps-lib/src/lib/components/glyph.svelte b/apps-lib/src/lib/components/glyph.svelte @@ -1,12 +1,12 @@ <script lang="ts"> - import { glyph_style_map } from "$lib/styles/glyphs"; + import { GLYPH_STYLE_MAP } from "$lib/styles/glyphs"; import type { IGlyph } from "$lib/types/components"; - import { fmt_cl } from "$lib/utils/app/lib"; + import { fmt_cl } from "$lib/utils/app"; let { basis }: { basis: IGlyph } = $props(); const styles = $derived( - basis?.dim ? glyph_style_map.get(basis.dim) : glyph_style_map.get(`sm`), + basis?.dim ? GLYPH_STYLE_MAP.get(basis.dim) : GLYPH_STYLE_MAP.get(`sm`), ); const weight = $derived(basis.weight ? `-${basis.weight}` : `-bold`); diff --git a/apps-lib/src/lib/components/glyphi.svelte b/apps-lib/src/lib/components/glyphi.svelte @@ -1,8 +1,8 @@ <script lang="ts"> import type { IGlyphI } from "$lib/types/components"; - import { fmt_cl } from "$lib/utils/app/lib"; + import { fmt_cl } from "$lib/utils/app"; - const styles = { + const GLYPH_SIZE_CLASSES = { xs: `text-[16px]`, sm: `text-[20px]`, md: `text-[26px]`, @@ -15,6 +15,6 @@ </script> <i - class={`ph${!basis?.weight || basis?.weight === `regular` ? `` : `-${basis?.weight}`} ph-${basis.key} ${fmt_cl(basis.classes)} ${basis.size ? styles[basis.size] : ""}`} + class={`ph${!basis?.weight || basis?.weight === `regular` ? `` : `-${basis?.weight}`} ph-${basis.key} ${fmt_cl(basis.classes)} ${basis.size ? GLYPH_SIZE_CLASSES[basis.size] : ""}`} > </i> diff --git a/apps-lib/src/lib/components/image-blob.svelte b/apps-lib/src/lib/components/image-blob.svelte @@ -1,23 +1,24 @@ <script lang="ts"> import type { IImageBlob } from "$lib/types/ui"; - import { fmt_cl, to_arr_buf } from "$lib/utils/app/lib"; + import { fmt_cl, to_arr_buf } from "$lib/utils/app"; let { basis }: { basis: IImageBlob } = $props(); - const img_src = $derived( - basis.data - ? URL.createObjectURL( - new Blob( - [ - basis.data instanceof Uint8Array - ? to_arr_buf(basis.data) - : basis.data, - ], - { type: "image/jpeg" }, - ), - ) - : undefined, - ); + let img_src = $state<string | undefined>(undefined); + + $effect(() => { + if (!basis.data) { + img_src = undefined; + return; + } + const url = URL.createObjectURL( + new Blob([to_arr_buf(basis.data)], { type: "image/jpeg" }) + ); + img_src = url; + return () => { + URL.revokeObjectURL(url); + }; + }); </script> {#if img_src} diff --git a/apps-lib/src/lib/components/image-path.svelte b/apps-lib/src/lib/components/image-path.svelte @@ -1,23 +0,0 @@ -<script lang="ts"> - import type { IImagePath } from "$lib/types/ui"; - import { get_context } from "$lib/utils/app/lib"; - import ImageBlob from "./image-blob.svelte"; - import ImageSrc from "./image-src.svelte"; - - const { lc_img_bin } = get_context(`lib`); - let { basis }: { basis: IImagePath } = $props(); - - const img_path = $derived(basis.path); -</script> - -{#if img_path} - {@const is_bin = img_path.startsWith(`file:`)} - - {#if is_bin} - {#await lc_img_bin(img_path) then data} - <ImageBlob basis={{ data, ...basis }} /> - {/await} - {:else} - <ImageSrc basis={{ src: img_path, ...basis }} /> - {/if} -{/if} diff --git a/apps-lib/src/lib/components/image-src.svelte b/apps-lib/src/lib/components/image-src.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IImageSource } from "$lib/types/ui"; - import { fmt_cl } from "$lib/utils/app/lib"; + import { fmt_cl } from "$lib/utils/app"; let { basis }: { basis: IImageSource } = $props(); diff --git a/apps-lib/src/lib/components/index.ts b/apps-lib/src/lib/components/index.ts @@ -0,0 +1,7 @@ +export { default as Fade } from "./fade.svelte"; +export { default as Flex } from "./flex.svelte"; +export { default as Glyph } from "./glyph.svelte"; +export { default as Glyphi } from "./glyphi.svelte"; +export { default as ImageBlob } from "./image-blob.svelte"; +export { default as ImageSrc } from "./image-src.svelte"; +export { default as Input } from "./input.svelte"; diff --git a/apps-lib/src/lib/components/input-ext.svelte b/apps-lib/src/lib/components/input-ext.svelte @@ -1,117 +0,0 @@ -<script lang="ts"> - import { browser } from "$app/environment"; - import type { IInput } from "$lib/types/components"; - import { fmt_cl, parse_layer, value_constrain } from "$lib/utils/app/lib"; - import { idb_kv } from "$lib/utils/keyval/lib"; - import { handle_err } from "@radroots/utils"; - - import { onMount } from "svelte"; - - let { - basis, - el = $bindable(null), - value = $bindable(``), - }: { - basis: IInput<string>; - el?: HTMLInputElement | null; - value?: string; - } = $props(); - - const id = $derived(basis?.id ? basis.id : null); - const layer = $derived( - typeof basis?.layer === `boolean` ? 0 : parse_layer(basis?.layer), - ); - const classes_layer = $derived( - typeof basis?.layer === `boolean` || typeof basis?.layer === `undefined` - ? `` - : `bg-ly${layer} text-ly${layer}-gl_d placeholder:text-ly${layer}-gl_pl caret-ly${layer}-gl`, - ); - - const sync_from_idb = async (): Promise<void> => { - if (!browser || !id) return; - try { - const kv_val = await idb_kv.get(id); - if (kv_val !== null && kv_val !== undefined && kv_val !== value) { - value = kv_val; - } else if (kv_val === null || kv_val === undefined) { - value = ``; - await idb_kv.set(id, ``); - } - } catch (e) { - handle_err(e, `sync_from_idb`); - } - }; - - const sync_to_idb = async (): Promise<void> => { - if (!browser || !id) return; - try { - await idb_kv.set(id, value || ``); - } catch (e) { - handle_err(e, `input_idb_sync`); - } - }; - - onMount(async () => { - await sync_from_idb(); - if (basis?.callback_mount && el) { - try { - await basis.callback_mount({ el }); - } catch (e) { - handle_err(e, `callback_mount`); - } - } - }); - - $effect(() => { - if (id && basis?.sync && browser) { - (async () => { - await sync_to_idb(); - })(); - } - }); - - const handle_on_input = async (): Promise<void> => { - try { - let val_cur = value; - let pass = true; - if (basis?.field) { - val_cur = value_constrain(basis.field?.charset, val_cur); - if (val_cur !== value) { - value = val_cur; - } - pass = basis.field?.validate.test(val_cur); - } - if (basis?.callback) { - await basis.callback({ value: val_cur, pass }); - } - } catch (e) { - handle_err(e, `handle_on_input`); - } - }; -</script> - -<input - bind:this={el} - bind:value - disabled={!!basis.disabled} - oninput={handle_on_input} - onblur={async ({ currentTarget: el }) => { - if (basis.callback_blur) await basis.callback_blur({ el }); - }} - onfocus={async ({ currentTarget: el }) => { - if (id && basis.sync && browser) await sync_from_idb(); - if (basis.callback_focus) await basis.callback_focus({ el }); - }} - onkeydown={async (ev) => { - if (basis?.callback_keydown) - await basis.callback_keydown({ - key: ev.key, - key_s: ev.key === `Enter`, - el: ev.currentTarget, - }); - }} - {id} - type="text" - class={`${fmt_cl(basis?.classes)} el-input ${classes_layer} el-re`} - placeholder={basis?.placeholder || ``} -/> diff --git a/apps-lib/src/lib/index.ts b/apps-lib/src/lib/index.ts @@ -1,25 +1,5 @@ -export * from "./index.js"; -export * from "./stores/app.js"; -export * from "./stores/carousel.js"; -export * from "./stores/theme.js"; -export * from "./styles/glyphs.js"; -export * from "./types/components.js"; -export * from "./types/lib.js"; -export * from "./types/ui.js"; -export * from "./utils/app/carousel.js"; -export * from "./utils/app/lib.js"; -export * from "./utils/browser.js"; -export * from "./utils/fetch/lib.js"; -export * from "./utils/geo.js"; -export * from "./utils/i18n.js"; -export * from "./utils/keyval/idb.js"; -export * from "./utils/keyval/lib.js"; -export { default as Fade } from "./components/fade.svelte"; -export { default as Flex } from "./components/flex.svelte"; -export { default as Glyph } from "./components/glyph.svelte"; -export { default as Glyphi } from "./components/glyphi.svelte"; -export { default as ImageBlob } from "./components/image-blob.svelte"; -export { default as ImagePath } from "./components/image-path.svelte"; -export { default as ImageSrc } from "./components/image-src.svelte"; -export { default as InputExt } from "./components/input-ext.svelte"; -export { default as Input } from "./components/input.svelte"; +export * from "./components/index.js"; +export * from "./stores/index.js"; +export * from "./styles/index.js"; +export * from "./types/index.js"; +export * from "./utils/index.js"; diff --git a/apps-lib/src/lib/stores/carousel.ts b/apps-lib/src/lib/stores/carousel.ts @@ -1,4 +1,4 @@ -import { get_store } from "$lib/utils/app/lib"; +import { get_store } from "$lib/utils/app"; import { writable } from "svelte/store"; export const casl_active = writable<boolean>(false); diff --git a/apps-lib/src/lib/stores/index.ts b/apps-lib/src/lib/stores/index.ts @@ -0,0 +1,3 @@ +export * from "./app.js"; +export * from "./carousel.js"; +export * from "./theme.js"; diff --git a/apps-lib/src/lib/stores/theme.ts b/apps-lib/src/lib/stores/theme.ts @@ -1,4 +1,4 @@ -import { get_store } from "$lib/utils/app/lib"; +import { get_store } from "$lib/utils/app"; import type { ThemeKey, ThemeMode } from "@radroots/themes"; import { type CallbackPromiseGeneric } from "@radroots/utils"; import { writable } from "svelte/store"; diff --git a/apps-lib/src/lib/styles/glyphs.ts b/apps-lib/src/lib/styles/glyphs.ts @@ -1,6 +1,6 @@ import type { GeometryGlyphDimension } from "$lib/types/lib"; -export const glyph_style_map: Map<GeometryGlyphDimension, { gl_1: number; dim_1?: number; }> = new Map([ +export const GLYPH_STYLE_MAP: Map<GeometryGlyphDimension, { gl_1: number; dim_1?: number; }> = new Map([ ["xs--", { gl_1: 12 }], ["xs-", { gl_1: 12, dim_1: 17 }], ["xs", { gl_1: 15, dim_1: 18 }], @@ -15,4 +15,4 @@ export const glyph_style_map: Map<GeometryGlyphDimension, { gl_1: number; dim_1? ["lg", { gl_1: 28 }], ["xl", { gl_1: 30 }], ["xl+", { gl_1: 40 }], -]); -\ No newline at end of file +]); diff --git a/apps-lib/src/lib/styles/index.ts b/apps-lib/src/lib/styles/index.ts @@ -0,0 +1 @@ +export * from "./glyphs.js"; diff --git a/apps-lib/src/lib/types/components.ts b/apps-lib/src/lib/types/components.ts @@ -2,7 +2,6 @@ import type { ElementCallbackMount, ElementCallbackValue, ElementCallbackValueBl import type { CallbackPromiseGeneric, FormField } from "@radroots/utils"; import type { HTMLInputTypeAttribute } from "svelte/elements"; - export type EntryStyle = `guide` | `line`; export type IGlyphI = { diff --git a/apps-lib/src/lib/types/index.ts b/apps-lib/src/lib/types/index.ts @@ -0,0 +1,3 @@ +export * from "./components.js"; +export * from "./lib.js"; +export * from "./ui.js"; diff --git a/apps-lib/src/lib/utils/app/carousel.ts b/apps-lib/src/lib/utils/app/carousel.ts @@ -1,7 +1,6 @@ - import { casl_active, casl_i, casl_imax, casl_num } from "$lib/stores/carousel"; import { exe_iter } from "@radroots/utils"; -import { get_store } from "./lib"; +import { get_store } from "./index"; const CAROUSEL_DELAY_MS = 150; diff --git a/apps-lib/src/lib/utils/app/index.ts b/apps-lib/src/lib/utils/app/index.ts @@ -0,0 +1,252 @@ +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { win_h, win_w } from '$lib/stores/app'; +import type { CallbackRoute, NavigationParamTuple, NavigationRouteParamKey, NavigationRouteParamTuple, } from '$lib/types/lib'; +import type { ThemeLayer, ThemeMode } from '@radroots/themes'; +import type { WebFilePath } from '@radroots/utils'; +import { getContext, setContext } from "svelte"; +import { get } from "svelte/store"; + +export const SYMBOLS = { + bullet: '•', + dash: `—`, + up: `↑`, + down: `↓`, + percent: `%` +}; + +export const get_store = get; + +export const get_context = <T>(key: string): T => + getContext<T>(key); + +export const set_context = <T>(key: string, value: T): T => + setContext(key, value); + +export const sleep = (ms: number): Promise<void> => + new Promise((r) => setTimeout(r, ms)); + +export const trim_slashes = (path: string): string => + path.replace(/^\/+|\/+$/g, ''); + +export const normalize_path = (path: string): string => + path + .replace(/-/g, '_') + .replace(/\//g, '-') + .replace(/-+/g, '-'); + +export const sanitize_path = (id: string): string => + id.replace(/[^A-Za-z0-9_-]+/g, ''); + +export const fmt_id = (raw_id?: string): string => { + if (!browser) return ''; + const pathname = window.location.pathname; + const trimmed = trim_slashes(pathname); + const prefix = normalize_path(trimmed); + const suffix = raw_id ? `-${sanitize_path(raw_id)}` : ''; + return `*${prefix}${suffix}`; +}; + +export const view_effect = <T extends string>(view: T): void => { + if (!browser) return; + for (const el of document.querySelectorAll(`[data-view]`)) { + if (el.getAttribute(`data-view`) !== view) el.classList.add(`hidden`) + else el.classList.remove(`hidden`) + } +}; + +export const el_id = (id: string): HTMLElement | undefined => { + if (!browser) return undefined; + const el = document.getElementById(id); + return el ? el : undefined; +}; + +export const build_storage_key = ( + raw_id: string, + base_prefix: string +): string => + `${fmt_id()}-${sanitize_path(raw_id)}` + .replace(new RegExp(`^\\*${normalize_path(trim_slashes(base_prefix))}-?`), '*'); + +export const get_system_theme = (fallback: ThemeMode = "light"): ThemeMode => { + if (!browser) return fallback; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +}; + +export const theme_set = (theme_key: string, color_mode: ThemeMode): void => { + if (!browser) return; + document.documentElement.setAttribute("data-theme", `${theme_key}_${color_mode}`); +}; +export const fmt_cl = (classes?: string): string => `${classes || ``}`; + +export const handle_err = async (e: unknown, fcall: string): Promise<void> => { + try { + console.log(`[handle_err] `, e, fcall) + /*return void await catch_err(e, fcall, async (opts) => { + console.log(`handle_err e `, e) + console.log(JSON.stringify(opts, null, 4), `handle_err opts`) + });*/ + } catch (e) { + console.log(`(handle_err) `, e) + } +}; + +export const window_set = (): void => { + if (!browser) return; + win_h.set(window.innerHeight); + win_w.set(window.innerWidth); +}; + +export const parse_layer = (layer?: number, layer_default?: ThemeLayer): ThemeLayer => { + switch (layer) { + case 0: + case 1: + case 2: + return layer; + default: + return layer_default ?? 0; + }; +}; + +export const value_constrain = (regex_charset: RegExp, value: string): string => { + return value + .split(``) + .filter((char) => regex_charset.test(char)) + .join(``); +}; + + +export const encode_query_params = <T extends string>(params_list: NavigationParamTuple<T>[] = []): string => { + let query = ""; + for (const [k, v] of params_list) { + if (k && v) { + if (query) query += `&`; + query += `${k.trim()}=${encodeURIComponent(v.trim())}`; + } + } + return query ? `?${query}` : ``; +}; + +export const encode_route = <TRoute extends string, TParam extends string>(route: TRoute, params_list?: NavigationParamTuple<TParam>[]): string => { + const query = encode_query_params(params_list); + if (!query) return route; + return `${route === `/` ? `/` : route.replace(/\/+$/, ``)}${query}`; +}; + +export const debounce = <TArgs extends readonly unknown[]>( + fn: (...args: TArgs) => void, + delay: number +): ((...args: TArgs) => void) => { + let timeout: ReturnType<typeof setTimeout> | undefined; + return (...args: TArgs) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }; +}; + +export const create_router = <T extends string>(): ((nav_route: T, params?: NavigationRouteParamTuple[]) => Promise<void>) => { + const router = async (nav_route: T, params: NavigationRouteParamTuple[] = []): Promise<void> => { + try { + if (params.length) await goto(encode_route<T, NavigationRouteParamKey>(nav_route, params)); + else await goto(nav_route); + } catch (e) { + handle_err(e, `route`); + }; + }; + return router; +}; + +export const get_locale = (locales: string[]): string => { + if (!browser) return (locales[0] ?? `en`).toLowerCase(); + const { language: navigator_locale } = navigator; + let locale = `en`; + if (locales.some(i => i === navigator_locale.toLowerCase())) locale = navigator.language; + else if (locales.some(i => i === navigator_locale.slice(0, 2).toLowerCase())) locale = navigator_locale.slice(0, 2); + return locale.toLowerCase(); +}; + +export const callback_route = async <T extends string>(callback_route: CallbackRoute<T>): Promise<void> => { + if (`route` in callback_route) { + if (typeof callback_route.route === `string`) return void await goto(callback_route.route); + else return void await goto( + encode_route<string, NavigationRouteParamKey>( + callback_route.route[0], + callback_route.route[1], + ), + ); + } + return void await callback_route(); +}; + +export const to_arr_buf = (u8: Uint8Array): ArrayBuffer => { + if (u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength && u8.buffer instanceof ArrayBuffer) return u8.buffer; + if (u8.buffer instanceof ArrayBuffer) return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + const copy = new Uint8Array(u8.byteLength); + copy.set(u8); + return copy.buffer; +}; + +export const parse_file_path = (file_path: string): WebFilePath | undefined => { + if (file_path.startsWith("blob:")) return { blob_path: file_path, blob_name: file_path.replaceAll("blob:", "").replaceAll("http://", "") }; + const file_path_spl = file_path.split(`/`); + const file_path_file = file_path_spl[file_path_spl.length - 1] || ``; + const [file_name, mime_type] = file_path_file.split(`.`); + if (!file_name || !mime_type) return undefined; + return { + file_path, + file_name, + mime_type + }; +}; + +export const download_json = <T>(data: T, filename: string): void => { + if (!browser) return; + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}; + +export const select_file = async (): Promise<File | undefined> => { + if (!browser) return undefined; + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "*/*"; + input.style.display = "none"; + const cleanup = () => { + input.remove(); + }; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + cleanup(); + resolve(file ?? undefined); + }); + document.body.appendChild(input); + input.click(); + }); +}; + +export const get_file_text = async (file: File | null): Promise<string | undefined> => { + if (!file) return undefined; + const text = await file.text(); + return text; +}; + +export type ParseJsonResult = { ok: true; value: unknown } | { ok: false; error: Error }; + +export const parse_file_json = async (file: File | null): Promise<ParseJsonResult> => { + const contents = await get_file_text(file); + if (!contents) return { ok: false, error: new Error("empty_file") }; + try { + const parsed: unknown = JSON.parse(contents); + return { ok: true, value: parsed }; + } catch (error) { + const err = error instanceof Error ? error : new Error("invalid_json"); + return { ok: false, error: err }; + } +}; diff --git a/apps-lib/src/lib/utils/app/lib.ts b/apps-lib/src/lib/utils/app/lib.ts @@ -1,234 +0,0 @@ -import { browser } from '$app/environment'; -import { goto } from '$app/navigation'; -import { win_h, win_w } from '$lib/stores/app'; -import type { CallbackRoute, NavigationParamTuple, NavigationRouteParamKey, NavigationRouteParamTuple, } from '$lib/types/lib'; -import type { ThemeLayer, ThemeMode } from '@radroots/themes'; -import type { WebFilePath } from '@radroots/utils'; -import { getContext, setContext } from "svelte"; -import { get } from "svelte/store"; - -export const symbols = { - bullet: '•', - dash: `—`, - up: `↑`, - down: `↓`, - percent: `%` -}; - -export const get_store = get; - -export const get_context = <M extends Record<string, any>, K extends keyof M>(key: K): M[K] => - getContext(key as string) as M[K]; - -export const set_context = <M extends Record<string, any>, K extends keyof M>(key: K, value: M[K]): void => - setContext(key as string, value); - -export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export const trim_slashes = (path: string): string => - path.replace(/^\/+|\/+$/g, ''); - -export const normalize_path = (path: string): string => - path - .replace(/-/g, '_') - .replace(/\//g, '-') - .replace(/-+/g, '-'); - -export const sanitize_path = (id: string): string => - id.replace(/[^A-Za-z0-9_-]+/g, ''); - -export const fmt_id = (raw_id?: string): string => { - if (!browser) return ''; - const pathname = window.location.pathname; - const trimmed = trim_slashes(pathname); - const prefix = normalize_path(trimmed); - const suffix = raw_id ? `-${sanitize_path(raw_id)}` : ''; - return `*${prefix}${suffix}`; -}; - -export const view_effect = <T extends string>(view: T): void => { - if (!browser) return; - for (const el of document.querySelectorAll(`[data-view]`)) { - if (el.getAttribute(`data-view`) !== view) el.classList.add(`hidden`) - else el.classList.remove(`hidden`) - } -}; - -export const el_id = (id: string): HTMLElement | undefined => { - const el = document.getElementById(id); - return el ? el : undefined; -}; - -export const build_storage_key = ( - raw_id: string, - base_prefix: string -): string => - `${fmt_id()}-${sanitize_path(raw_id)}` - .replace(new RegExp(`^\\*${normalize_path(trim_slashes(base_prefix))}-?`), '*'); - -export const get_system_theme = (): ThemeMode => { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -}; - -export const theme_set = (theme_key: string, color_mode: ThemeMode): void => { - document.documentElement.setAttribute("data-theme", `${theme_key}_${color_mode}`); -}; -export const fmt_cl = (classes?: string): string => `${classes || ``}`; - -export const handle_err = async (e: unknown, fcall: string): Promise<void> => { - try { - console.log(`[handle_err] `, e, fcall) - /*return void await catch_err(e, fcall, async (opts) => { - console.log(`handle_err e `, e) - console.log(JSON.stringify(opts, null, 4), `handle_err opts`) - });*/ - } catch (e) { - console.log(`(handle_err) `, e) - } -}; - -export const window_set = (): void => { - if (!browser) return; - win_h.set(window.innerHeight); - win_w.set(window.innerWidth); -}; - -export const parse_layer = (layer?: number, layer_default?: ThemeLayer): ThemeLayer => { - switch (layer) { - case 0: - case 1: - case 2: - return layer; - default: - return layer_default ? layer_default : 0; - }; -}; - -export const value_constrain = (regex_charset: RegExp, value: string): string => { - return value - .split(``) - .filter((char) => regex_charset.test(char)) - .join(``); -}; - - -export const encode_query_params = <T extends string>(params_list: NavigationParamTuple<T>[] = []): string => { - let query = ""; - for (const [k, v] of params_list) { - if (k && v) { - if (query) query += `&`; - query += `${k.trim()}=${encodeURIComponent(v.trim())}`; - } - } - return query ? `?${query}` : ``; -}; - -export const encode_route = <TRoute extends string, TParam extends string>(route: TRoute, params_list?: NavigationParamTuple<TParam>[]): string => { - const query = encode_query_params(params_list); - if (!query) return route; - return `${route === `/` ? `/` : route.replace(/\/+$/, ``)}${query}`; -}; - -export const debounce = <T extends (...args: any[]) => void>( - fn: T, - delay: number -): T => { - let timeout: ReturnType<typeof setTimeout>; - return ((...args: any[]) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), delay); - }) as T; -}; - -export const create_router = <T extends string>() => { - const router = async (nav_route: T, params: NavigationRouteParamTuple[] = []): Promise<void> => { - try { - if (params.length) await goto(encode_route<T, NavigationRouteParamKey>(nav_route, params)); - else await goto(nav_route); - } catch (e) { - handle_err(e, `route`); - }; - }; - return router; -}; - -export const get_locale = (locales: string[]): string => { - const { language: navigator_locale } = navigator; - let locale = `en`; - if (locales.some(i => i === navigator_locale.toLowerCase())) locale = navigator.language; - else if (locales.some(i => i === navigator_locale.slice(0, 2).toLowerCase())) locale = navigator_locale.slice(0, 2); - return locale.toLowerCase(); -}; - -export const callback_route = async <T extends string>(callback_route: CallbackRoute<T>): Promise<void> => { - if (`route` in callback_route) { - if (typeof callback_route.route === `string`) return void await goto(callback_route.route); - else return void await goto( - encode_route<string, NavigationRouteParamKey>( - callback_route.route[0], - callback_route.route[1], - ), - ); - } - return void await callback_route(); -}; - -export const to_arr_buf = (u8: Uint8Array): ArrayBuffer => { - return u8.slice().buffer; -}; - -export const parse_file_path = (file_path: string): WebFilePath | undefined => { - if (file_path.startsWith("blob:")) return { blob_path: file_path, blob_name: file_path.replaceAll("blob:", "").replaceAll("http://", "") }; - const file_path_spl = file_path.split(`/`); - const file_path_file = file_path_spl[file_path_spl.length - 1] || ``; - const [file_name, mime_type] = file_path_file.split(`.`); - if (!file_name || !mime_type) return undefined; - return { - file_path, - file_name, - mime_type - }; -}; - -export const download_json = (data: any, filename: string): void => { - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - URL.revokeObjectURL(url); -}; - -export const select_file = async (): Promise<File | undefined> => { - return new Promise((resolve) => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = "*/*"; - input.style.display = "none"; - const cleanup = () => { - input.remove(); - }; - input.addEventListener("change", async () => { - const file = input.files?.[0]; - cleanup(); - resolve(file ?? undefined); - }); - document.body.appendChild(input); - input.click(); - }); -}; - -export const get_file_text = async (file: File | null): Promise<string | undefined> => { - if (!file) return undefined; - const text = await file.text(); - return text; -}; - -export const parse_file_json = async (file: File | null): Promise<unknown | undefined> => { - const contents = await get_file_text(file); - if (!contents) return undefined; - const parsed: unknown = JSON.parse(contents); - return parsed; -}; diff --git a/apps-lib/src/lib/utils/browser.ts b/apps-lib/src/lib/utils/browser.ts @@ -4,39 +4,39 @@ export type BrowserPlatformInfo = { version: string; }; -const remove_excess_mozilla_and_version = /^mozilla\/\d\.\d\W/; -const browser_pattern = /(\w+)\/(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)/g; -const engine_and_version_pattern = /^(ver|cri|gec)/; -const brand_list = ['chrome', 'opera', 'safari', 'edge', 'firefox']; +const REMOVE_EXCESS_MOZILLA_AND_VERSION = /^mozilla\/\d\.\d\W/; +const BROWSER_PATTERN = /(\w+)\/(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)/g; +const ENGINE_AND_VERSION_PATTERN = /^(ver|cri|gec)/; +const BRAND_LIST = ['chrome', 'opera', 'safari', 'edge', 'firefox']; -const mobiles: Record<string, RegExp> = { +const MOBILE_OS_PATTERNS: Record<string, RegExp> = { iphone: /iphone/, ipad: /ipad|macintosh/, android: /android/ }; -const desktops: Record<string, RegExp> = { +const DESKTOP_OS_PATTERNS: Record<string, RegExp> = { windows: /win/, mac: /macintosh/, linux: /linux/ }; const parse_user_agent_string = (ua_string: string): BrowserPlatformInfo => { - const ua = ua_string.toLowerCase().replace(remove_excess_mozilla_and_version, ''); + const ua = ua_string.toLowerCase().replace(REMOVE_EXCESS_MOZILLA_AND_VERSION, ''); - const mobile_os = Object.keys(mobiles).find( - (key) => mobiles[key].test(ua) && navigator.maxTouchPoints >= 1 + const mobile_os = Object.keys(MOBILE_OS_PATTERNS).find( + (key) => MOBILE_OS_PATTERNS[key].test(ua) && navigator.maxTouchPoints >= 1 ); - const desktop_os = Object.keys(desktops).find((key) => desktops[key].test(ua)); + const desktop_os = Object.keys(DESKTOP_OS_PATTERNS).find((key) => DESKTOP_OS_PATTERNS[key].test(ua)); const os = mobile_os || desktop_os || ''; - const browser_matches = ua.match(browser_pattern); + const browser_matches = ua.match(BROWSER_PATTERN); const version_regex = /version\/(\d+(\.\d+)*)/; const safari_version_match = ua.match(version_regex); const safari_version = Array.isArray(safari_version_match) ? safari_version_match[1] : null; const browser_offset = - browser_matches && browser_matches.length > 2 && !engine_and_version_pattern.test(browser_matches[1]) + browser_matches && browser_matches.length > 2 && !ENGINE_AND_VERSION_PATTERN.test(browser_matches[1]) ? 1 : 0; const browser_result = @@ -50,10 +50,7 @@ const parse_user_agent_string = (ua_string: string): BrowserPlatformInfo => { export const browser_platform = (): BrowserPlatformInfo | undefined => { if (typeof navigator !== 'undefined') { if ('userAgentData' in navigator && navigator.userAgentData) { - const ua_data = navigator.userAgentData as { - platform: string; - brands: { brand: string; version: string }[]; - }; + const ua_data = navigator.userAgentData; const os = ua_data.platform.toLowerCase(); let browser = ''; let version = ''; @@ -61,7 +58,7 @@ export const browser_platform = (): BrowserPlatformInfo | undefined => { if (Array.isArray(ua_data.brands)) { for (const { brand, version: brand_version } of ua_data.brands) { const lower_brand = brand.toLowerCase(); - if (brand_list.some((b) => lower_brand.includes(b))) { + if (BRAND_LIST.some((b) => lower_brand.includes(b))) { browser = lower_brand; version = brand_version; break; diff --git a/apps-lib/src/lib/utils/fetch.ts b/apps-lib/src/lib/utils/fetch.ts @@ -0,0 +1,43 @@ +export type HttpFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; + +export type FetchJsonErrorKind = "http" | "network" | "parse"; + +export type FetchJsonError = { + ok: false; + kind: FetchJsonErrorKind; + url: string; + message: string; + status?: number; + status_text?: string; +}; + +export type FetchJsonResult<T> = { ok: true; data: T } | FetchJsonError; + +export async function fetch_json<T>(fetch_fn: HttpFetch, url: string): Promise<FetchJsonResult<T>> { + let res: Response; + try { + res = await fetch_fn(url); + } catch (error) { + const message = error instanceof Error ? error.message : "network_error"; + return { ok: false, kind: "network", url, message }; + } + + if (!res.ok) { + return { + ok: false, + kind: "http", + url, + message: res.statusText || "http_error", + status: res.status, + status_text: res.statusText + }; + } + + try { + const data: T = await res.json(); + return { ok: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : "parse_error"; + return { ok: false, kind: "parse", url, message }; + } +} diff --git a/apps-lib/src/lib/utils/fetch/lib.ts b/apps-lib/src/lib/utils/fetch/lib.ts @@ -1,7 +0,0 @@ -export type HttpFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; - -export async function fetch_json<T>(fetch_fn: HttpFetch, url: string): Promise<T> { - const res = await fetch_fn(url); - if (!res.ok) throw new Error(url); - return res.json() as Promise<T>; -} -\ No newline at end of file diff --git a/apps-lib/src/lib/utils/i18n.ts b/apps-lib/src/lib/utils/i18n.ts @@ -8,35 +8,37 @@ type LanguageConfig = { value?: string; }; -const lib_config: Config<LanguageConfig> = { +type TranslationMap<T extends string> = Record<T, Record<string, unknown>>; + +const LIB_CONFIG: Config<LanguageConfig> = { initLocale: `en`, fallbackLocale: `en`, translations: {}, loaders: [], }; -const lib_i18n = new i18n(lib_config); +const lib_i18n = new i18n(LIB_CONFIG); export type I18nTranslateFunction = typeof lib_i18n.t; export type I18nTranslateLocale = typeof lib_i18n.locale; export const i18n_conf = <T extends string>(opts: { default_locale: T; - translations: Record<T, any>; + translations: TranslationMap<T>; loaders: Loader.LoaderModule[] }) => { const { default_locale, translations, loaders } = opts; - const config: Config<any> = { + const config = { initLocale: default_locale, fallbackLocale: default_locale, translations, loaders, - }; + } satisfies Config<Record<string, unknown>>; return new i18n(config); }; export const i18n_conf_icu = <T extends string>(opts: { default_locale: T; - translations: Record<T, any>; + translations: TranslationMap<T>; loaders: Loader.LoaderModule[] }): i18n<ParserIcu.Params<LanguageConfig>> => { const { default_locale, translations, loaders } = opts; @@ -48,4 +50,4 @@ export const i18n_conf_icu = <T extends string>(opts: { loaders, }; return new i18n(config); -}; -\ No newline at end of file +}; diff --git a/apps-lib/src/lib/utils/index.ts b/apps-lib/src/lib/utils/index.ts @@ -0,0 +1,7 @@ +export * from "./app/index.js"; +export * from "./app/carousel.js"; +export * from "./browser.js"; +export * from "./fetch.js"; +export * from "./geo.js"; +export * from "./i18n.js"; +export * from "./keyval/idb.js"; diff --git a/apps-lib/src/lib/utils/keyval/idb.ts b/apps-lib/src/lib/utils/keyval/idb.ts @@ -1,96 +1,109 @@ -export class IdbKeyval { - static readonly unbound = IDBKeyRange.lowerBound(Number.MIN_SAFE_INTEGER); +export interface IIdbKeyval { + get<T = unknown>(key: IdbKeyval.Key): Promise<T>; + get<T = unknown>(keys: IdbKeyval.Key[]): Promise<T[]>; + each<T = unknown>(): Promise<[IdbKeyval.Key, T][]>; + each<T = unknown>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; + each(options: IdbKeyval.IQuery, only: "keys"): Promise<IdbKeyval.Key[]>; + each<T = unknown>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; + set(key: IdbKeyval.Key, value: unknown): Promise<void>; + set(entries: [IdbKeyval.Key, unknown][]): Promise<void>; + delete(): Promise<void>; + delete(range: IDBKeyRange): Promise<void>; + delete(key: IdbKeyval.Key): Promise<void>; + delete(keys: IdbKeyval.Key[]): Promise<void>; +} + +const is_entry_list = ( + value: IdbKeyval.Key | [IdbKeyval.Key, unknown][] +): value is [IdbKeyval.Key, unknown][] => { + if (!Array.isArray(value)) return false; + if (value.length === 0) return true; + for (const entry of value) { + if (!Array.isArray(entry)) return false; + if (entry.length !== 2) return false; + } + return true; +}; + +export class IdbKeyval implements IIdbKeyval { + static readonly UNBOUND = IDBKeyRange.lowerBound(Number.MIN_SAFE_INTEGER); - static prefix(prefix: string) { + static prefix(prefix: string): IDBKeyRange { return IDBKeyRange.bound(prefix, prefix + "\uFFFF"); } - static async each() { + static async each(): Promise<string[]> { const databases = await indexedDB.databases(); return databases .map((db) => db.name) - .filter((name): name is string => !!name && name.startsWith(this.kv_prefix)); + .filter((name): name is string => !!name && name.startsWith(this.KV_PREFIX)); } - static async delete(...names: string[]) { + static async delete(...names: string[]): Promise<void> { const resolved_names = names.length - ? names.map((name) => (name.startsWith(this.kv_prefix) ? name : this.kv_prefix + name)) + ? names.map((name) => (name.startsWith(this.KV_PREFIX) ? name : this.KV_PREFIX + name)) : await this.each(); - Promise.all(resolved_names.map((name) => this.as_promise(indexedDB.deleteDatabase(name)))); + await Promise.all(resolved_names.map((name) => this.as_promise(indexedDB.deleteDatabase(name)))); } - private static readonly kv_prefix = "radroots-web-keyval"; + private static readonly KV_PREFIX = "radroots-web-keyval"; constructor(options: IdbKeyval.IConstructorOptions = {}) { const idx = options.indexes || []; this.indexes = (Array.isArray(idx) ? idx : [idx]).sort(); - this.name = options.name?.toString() || IdbKeyval.kv_prefix; + this.name = options.name?.toString() || IdbKeyval.KV_PREFIX; } private readonly indexes: string[]; private readonly name: string; - get<T = any>(key: IdbKeyval.Key): Promise<T>; - get<T = any>(keys: IdbKeyval.Key[]): Promise<T[]>; - async get(k: IdbKeyval.Key | IdbKeyval.Key[]) { + get<T = unknown>(key: IdbKeyval.Key): Promise<T>; + get<T = unknown>(keys: IdbKeyval.Key[]): Promise<T[]>; + async get<T = unknown>(k: IdbKeyval.Key | IdbKeyval.Key[]): Promise<T | T[]> { const store = await this.get_store("readonly"); return Array.isArray(k) - ? Promise.all(k.map((key) => IdbKeyval.as_promise(store.get(key)))) - : IdbKeyval.as_promise(store.get(k)); + ? Promise.all(k.map((key) => IdbKeyval.as_promise<T>(store.get(key)))) + : IdbKeyval.as_promise<T>(store.get(k)); } - each<T = any>(): Promise<[IdbKeyval.Key, T][]>; - each<T = any>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; + each<T = unknown>(): Promise<[IdbKeyval.Key, T][]>; + each<T = unknown>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; each(options: IdbKeyval.IQuery, only: "keys"): Promise<IdbKeyval.Key[]>; - each<T = any>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; - async each(options: IdbKeyval.IQuery = {}, only?: "keys" | "values"): Promise<any> { + each<T = unknown>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; + async each<T = unknown>( + options: IdbKeyval.IQuery = {}, + only?: "keys" | "values" + ): Promise<[IdbKeyval.Key, T][] | IdbKeyval.Key[] | T[]> { const store = await this.get_store("readonly"); const target = options.index ? store.index(options.index) : store; const limit = options.limit; const range = options.range; if (only === "keys") { - return IdbKeyval.as_promise(target.getAllKeys(range, limit)); + return IdbKeyval.as_promise<IdbKeyval.Key[]>(target.getAllKeys(range, limit)); } if (only === "values") { - return IdbKeyval.as_promise(target.getAll(range, limit)); + return IdbKeyval.as_promise<T[]>(target.getAll(range, limit)); } - const keys: IdbKeyval.Key[] = []; - const values: any[] = []; - - await Promise.allSettled([ - new Promise<void>(async (resolve) => { - const results = await IdbKeyval.as_promise(target.getAllKeys(range, limit)); - keys.push(...(results as IdbKeyval.Key[])); - resolve(); - }), - new Promise<void>(async (resolve) => { - const results = await IdbKeyval.as_promise(target.getAll(range, limit)); - values.push(...results); - resolve(); - }), + const [keys, values] = await Promise.all([ + IdbKeyval.as_promise<IdbKeyval.Key[]>(target.getAllKeys(range, limit)), + IdbKeyval.as_promise<T[]>(target.getAll(range, limit)) ]); - const tuples: [IdbKeyval.Key, any][] = []; - - for (let i = -1; ++i < keys.length;) { - tuples.push([keys[i], values[i]]); - } - - return tuples; + return keys.map<[IdbKeyval.Key, T]>((key, index) => [key, values[index]]); } - async set(key: IdbKeyval.Key, value: any): Promise<void>; - async set(entries: [IdbKeyval.Key, any][]): Promise<void>; - async set(a: any, b?: any) { + async set(key: IdbKeyval.Key, value: unknown): Promise<void>; + async set(entries: [IdbKeyval.Key, unknown][]): Promise<void>; + async set(a: IdbKeyval.Key | [IdbKeyval.Key, unknown][], b?: unknown): Promise<void> { const store = await this.get_store("readwrite"); - if (Array.isArray(a)) { - for (const entry of a as [IdbKeyval.Key, any][]) { - store.put(entry[1], entry[0]); + if (is_entry_list(a)) { + for (const [key, value] of a) { + store.put(value, key); } return IdbKeyval.as_promise(store.transaction); @@ -104,9 +117,9 @@ export class IdbKeyval { async delete(range: IDBKeyRange): Promise<void>; async delete(key: IdbKeyval.Key): Promise<void>; async delete(keys: IdbKeyval.Key[]): Promise<void>; - async delete(arg?: IdbKeyval.Key | IdbKeyval.Key[] | IDBKeyRange) { + async delete(arg?: IdbKeyval.Key | IdbKeyval.Key[] | IDBKeyRange): Promise<void> { const store = await this.get_store("readwrite"); - const delete_arg = arg ?? IdbKeyval.unbound; + const delete_arg = arg ?? IdbKeyval.UNBOUND; if (Array.isArray(delete_arg)) { for (const key of delete_arg) { @@ -119,12 +132,12 @@ export class IdbKeyval { return IdbKeyval.as_promise(store.transaction); } - private async get_store(mode: IDBTransactionMode) { + private async get_store(mode: IDBTransactionMode): Promise<IDBObjectStore> { const db = await this.get_database(); return db.transaction(this.name, mode).objectStore(this.name); } - private async get_database() { + private async get_database(): Promise<IDBDatabase> { if (!this.database) { await this.maybe_fix_safari(); let quit = false; @@ -136,7 +149,8 @@ export class IdbKeyval { const request = indexedDB.open(this.name, version); request.onupgradeneeded = () => { const db = request.result; - const tx = request.transaction as IDBTransaction; + const tx = request.transaction; + if (!tx) return; const store = tx.objectStoreNames.contains(this.name) ? tx.objectStore(this.name) @@ -179,13 +193,13 @@ export class IdbKeyval { private database: IDBDatabase | null = null; - private async maybe_fix_safari() { + private async maybe_fix_safari(): Promise<void> { if (!/Version\/14\.\d*\s*Safari\//.test(navigator.userAgent)) { return; } let id: ReturnType<typeof setInterval> | undefined; - return new Promise<void>((resolve) => { + await new Promise<void>((resolve) => { const hit = () => indexedDB.databases().finally(resolve); id = setInterval(hit, 50); hit(); @@ -196,11 +210,19 @@ export class IdbKeyval { }); } - private static as_promise<T = undefined>(request: IDBRequest<T> | IDBTransaction) { - return new Promise<T>((resolve, reject) => { - const req = request as IDBRequest<T> & IDBTransaction; - req.oncomplete = req.onsuccess = () => resolve(req.result); - req.onabort = req.onerror = () => reject(req.error); + private static as_promise<T>(request: IDBRequest<T>): Promise<T>; + private static as_promise(request: IDBTransaction): Promise<void>; + private static as_promise<T>(request: IDBRequest<T> | IDBTransaction): Promise<T | void> { + return new Promise<T | void>((resolve, reject) => { + if ("onsuccess" in request) { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + return; + } + + request.oncomplete = () => resolve(); + request.onabort = () => reject(request.error); + request.onerror = () => reject(request.error); }); } } @@ -217,5 +239,5 @@ export namespace IdbKeyval { limit?: number; } - export type Key = string | number | Date | BufferSource; + export type Key = IDBValidKey; } diff --git a/apps-lib/src/lib/utils/keyval/lib.ts b/apps-lib/src/lib/utils/keyval/lib.ts @@ -1,52 +0,0 @@ -import { browser } from "$app/environment"; -import { _env_lib } from "../_env"; -import { fmt_id } from "../app/lib"; -import { IdbKeyval } from "./idb"; - -export let idb_kv: IdbKeyval; -if (browser) idb_kv = new IdbKeyval({ name: _env_lib.KEYVAL_NAME }); - -export const idb_kv_init = async (): Promise<void> => { - if (!browser) return; - const range = IdbKeyval.prefix(`*`); - const idb_kv_list = await idb_kv.each({ range }, `keys`); - await Promise.all(idb_kv_list.map((i) => idb_kv.delete(i))); -}; - -export const idb_kv_init_page = async (): Promise<void> => { - if (!browser) return; - const idb_kv_pref = fmt_id(); - const range = IdbKeyval.prefix(idb_kv_pref); - const idb_kv_list = await idb_kv.each({ range }, `keys`); - await Promise.all(idb_kv_list.map((i) => idb_kv.delete(i))); -}; - -export const idb_kv_sync = async (list: [string, string][]): Promise<void> => { - if (!browser) return; - for (const [key, val] of list) await idb_kv.set(key, val); -}; - -export class IdbLib<T extends string> { - private _idb: IdbKeyval; - - constructor(kv: IdbKeyval) { - this._idb = kv; - } - public init = async () => { - await idb_kv_init_page(); - } - - public save = async (key: T, value: string) => { - await this._idb.set(fmt_id(key), value); - } - - public read = async (key: T): Promise<string | undefined> => { - const result = await this._idb.get<string>(fmt_id(key)); - if (result) return result; - return undefined; - } - - public del = async (key: T) => { - await this._idb.delete(fmt_id(key)); - } -} -\ No newline at end of file diff --git a/apps-lib/svelte.config.js b/apps-lib/svelte.config.js @@ -2,10 +2,10 @@ import adapter from '@sveltejs/adapter-auto'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const config = { - preprocess: vitePreprocess(), - kit: { - adapter: adapter(), - }, + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, }; export default config; diff --git a/apps-lib/vite.config.ts b/apps-lib/vite.config.ts @@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit() as any] + plugins: [sveltekit()] }); diff --git a/client/src/backup/index.ts b/client/src/backup/index.ts @@ -13,7 +13,7 @@ import type { import type { IWebCryptoService, KeyMaterialProvider } from "../crypto/types.js"; import { DeviceKeyMaterialProvider } from "../crypto/provider.js"; import { handle_err, type ResolveError } from "@radroots/utils"; -import type { IError } from "@radroots/types-bindings"; +import { is_error } from "../utils/resolve.js"; export type BackupBundleBuildOpts = { sql_store?: BackupSqlStore; @@ -33,10 +33,6 @@ export type BackupBundleImportOpts = { import_registry?: boolean; }; -const is_error = <T>(value: ResolveError<T>): value is IError<string> => { - return typeof value === "object" && value !== null && "err" in value; -}; - const collect_payloads = async (opts: BackupBundleBuildOpts): Promise<ResolveError<BackupBundlePayload[]>> => { const payloads: BackupBundlePayload[] = []; if (opts.sql_store) { @@ -79,6 +75,7 @@ export const backup_bundle_build = async (opts: BackupBundleBuildOpts): Promise< const crypto_registry = opts.crypto_service ? await opts.crypto_service.export_registry() : { stores: [], keys: [] }; + if (is_error(crypto_registry)) return crypto_registry; return { manifest: { version: 1, @@ -107,7 +104,8 @@ export const backup_bundle_import = async (blob: Uint8Array, opts: BackupBundleI const provider = opts.key_material_provider ?? new DeviceKeyMaterialProvider(); const bundle = await backup_bundle_decode(blob, provider); if (opts.import_registry && opts.crypto_service) { - await opts.crypto_service.import_registry(bundle.manifest.crypto_registry); + const res = await opts.crypto_service.import_registry(bundle.manifest.crypto_registry); + if (is_error(res)) return res; } for (const payload of bundle.payloads) { if (payload.store_type === "sql" && opts.sql_store) { diff --git a/client/src/cipher/types.ts b/client/src/cipher/types.ts @@ -1,8 +1,20 @@ -import { type IdbClientConfig } from "@radroots/utils"; +import { type IdbClientConfig, type ResolveError, type ResultPass } from "@radroots/utils"; + +export type ClientCipherEncryptResolve = ResolveError<Uint8Array>; +export type ClientCipherDecryptResolve = ResolveError<Uint8Array>; +export type ClientCipherResetResolve = ResolveError<ResultPass>; + +export type WebAesGcmCipherConfig = { + idb_config?: Partial<IdbClientConfig>; + key_name?: string; + key_length?: number; + iv_length?: number; + algorithm?: string; +}; export interface IClientCipher { get_config(): IdbClientConfig; - reset(): Promise<void>; - encrypt(data: Uint8Array): Promise<Uint8Array>; - decrypt(blob: Uint8Array): Promise<Uint8Array>; + reset(): Promise<ClientCipherResetResolve>; + encrypt(data: Uint8Array): Promise<ClientCipherEncryptResolve>; + decrypt(blob: Uint8Array): Promise<ClientCipherDecryptResolve>; } diff --git a/client/src/cipher/web.ts b/client/src/cipher/web.ts @@ -1,13 +1,13 @@ -import { type IdbClientConfig } from "@radroots/utils"; +import { err_msg, handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; import { createStore, del as idb_del, type UseStore } from "idb-keyval"; -import type { WebAesGcmCipherConfig } from "../keystore/web.js"; import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js"; import { WebCryptoService } from "../crypto/service.js"; import type { LegacyKeyConfig } from "../crypto/types.js"; import { IDB_CONFIG_CIPHER_AES_GCM } from "../idb/config.js"; -import { idb_store_ensure } from "../idb/store.js"; +import { idb_store_ensure, idb_store_exists } from "../idb/store.js"; +import { is_error } from "../utils/resolve.js"; import { cl_cipher_error } from "./error.js"; -import type { IClientCipher } from "./types.js"; +import type { ClientCipherDecryptResolve, ClientCipherEncryptResolve, ClientCipherResetResolve, IClientCipher, WebAesGcmCipherConfig } from "./types.js"; const DEFAULT_IDB_CONFIG: IdbClientConfig = IDB_CONFIG_CIPHER_AES_GCM; @@ -42,9 +42,6 @@ export class WebAesGcmCipher implements IWebAesGcmCipher { ? config?.iv_length ?? DEFAULT_WEB_AES_GCM_CONFIG.iv_length : DEFAULT_WEB_AES_GCM_CONFIG.iv_length; - if (typeof indexedDB === "undefined") throw new Error(cl_cipher_error.idb_undefined); - if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_cipher_error.crypto_undefined); - this.legacy_store = null; this.store_id = this.key_name; this.crypto = new WebCryptoService(); @@ -71,30 +68,63 @@ export class WebAesGcmCipher implements IWebAesGcmCipher { }; } - private async get_store(): Promise<UseStore> { - if (!this.store_ready) this.store_ready = idb_store_ensure(this.db_name, this.store_name); - await this.store_ready; - if (!this.legacy_store) this.legacy_store = createStore(this.db_name, this.store_name); - return this.legacy_store; + private ensure_env(): ResolveError<void> { + if (typeof indexedDB === "undefined") return err_msg(cl_cipher_error.idb_undefined); + if (!globalThis.crypto || !globalThis.crypto.subtle) return err_msg(cl_cipher_error.crypto_undefined); + return; + } + + private async get_store(): Promise<ResolveError<UseStore>> { + const env_err = this.ensure_env(); + if (env_err) return env_err; + try { + if (!this.store_ready) this.store_ready = idb_store_ensure(this.db_name, this.store_name); + await this.store_ready; + if (!this.legacy_store) this.legacy_store = createStore(this.db_name, this.store_name); + return this.legacy_store; + } catch (e) { + return handle_err(e); + } } - public async reset(): Promise<void> { - const store = await this.get_store(); - const index = await crypto_registry_get_store_index(this.store_id); - if (index) { - await crypto_registry_clear_store_index(this.store_id); - for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id); + public async reset(): Promise<ClientCipherResetResolve> { + const env_err = this.ensure_env(); + if (env_err) return env_err; + try { + const index = await crypto_registry_get_store_index(this.store_id); + if (is_error(index)) return index; + if (index) { + const cleared = await crypto_registry_clear_store_index(this.store_id); + if (is_error(cleared)) return cleared; + for (const key_id of index.key_ids) { + const res = await crypto_registry_clear_key_entry(key_id); + if (is_error(res)) return res; + } + } + const has_store = await idb_store_exists(this.db_name, this.store_name); + if (has_store) { + const store = await this.get_store(); + if (is_error(store)) return store; + await idb_del(this.key_name, store); + } + return { pass: true } as const; + } catch (e) { + return handle_err(e); } - await idb_del(this.key_name, store); } - public async encrypt(data: Uint8Array): Promise<Uint8Array> { + public async encrypt(data: Uint8Array): Promise<ClientCipherEncryptResolve> { + const env_err = this.ensure_env(); + if (env_err) return env_err; return await this.crypto.encrypt(this.store_id, data); } - public async decrypt(blob: Uint8Array): Promise<Uint8Array> { - if (blob.byteLength <= this.iv_length) throw new Error(cl_cipher_error.invalid_ciphertext); + public async decrypt(blob: Uint8Array): Promise<ClientCipherDecryptResolve> { + const env_err = this.ensure_env(); + if (env_err) return env_err; + if (blob.byteLength <= this.iv_length) return err_msg(cl_cipher_error.invalid_ciphertext); const outcome = await this.crypto.decrypt_record(this.store_id, blob); + if (is_error(outcome)) return outcome; return outcome.plaintext; } } diff --git a/client/src/crypto/envelope.ts b/client/src/crypto/envelope.ts @@ -4,6 +4,8 @@ import type { CryptoEnvelope } from "./types.js"; const ENVELOPE_MAGIC = new Uint8Array([0x52, 0x52, 0x43, 0x45]); const ENVELOPE_VERSION = 1; const ENVELOPE_HEADER_LENGTH = 4 + 1 + 1 + 1 + 8; +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); const bytes_equal = (left: Uint8Array, right: Uint8Array): boolean => { if (left.length !== right.length) return false; @@ -12,8 +14,7 @@ const bytes_equal = (left: Uint8Array, right: Uint8Array): boolean => { }; export const crypto_envelope_encode = (envelope: CryptoEnvelope): Uint8Array => { - const encoder = new TextEncoder(); - const key_bytes = encoder.encode(envelope.key_id); + const key_bytes = TEXT_ENCODER.encode(envelope.key_id); if (key_bytes.length > 255) throw new Error(cl_crypto_error.invalid_key_id); const total_len = ENVELOPE_HEADER_LENGTH + key_bytes.length + envelope.iv.length + envelope.ciphertext.length; const out = new Uint8Array(total_len); @@ -39,7 +40,7 @@ export const crypto_envelope_encode = (envelope: CryptoEnvelope): Uint8Array => export const crypto_envelope_decode = (blob: Uint8Array): CryptoEnvelope | null => { if (blob.byteLength < ENVELOPE_HEADER_LENGTH) return null; - const magic = blob.slice(0, ENVELOPE_MAGIC.length); + const magic = blob.subarray(0, ENVELOPE_MAGIC.length); if (!bytes_equal(magic, ENVELOPE_MAGIC)) return null; const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength); let offset = ENVELOPE_MAGIC.length; @@ -54,13 +55,12 @@ export const crypto_envelope_decode = (blob: Uint8Array): CryptoEnvelope | null offset += 8; const remaining = blob.byteLength - offset; if (remaining < key_len + iv_len + 1) throw new Error(cl_crypto_error.invalid_envelope); - const key_bytes = blob.slice(offset, offset + key_len); + const key_bytes = blob.subarray(offset, offset + key_len); offset += key_len; - const iv = blob.slice(offset, offset + iv_len); + const iv = blob.subarray(offset, offset + iv_len); offset += iv_len; - const ciphertext = blob.slice(offset); - const decoder = new TextDecoder(); - const key_id = decoder.decode(key_bytes); + const ciphertext = blob.subarray(offset); + const key_id = TEXT_DECODER.decode(key_bytes); if (!key_id) throw new Error(cl_crypto_error.invalid_key_id); return { version, diff --git a/client/src/crypto/provider.ts b/client/src/crypto/provider.ts @@ -1,6 +1,7 @@ import { cl_crypto_error } from "./error.js"; import type { KeyMaterialProvider } from "./types.js"; import { crypto_registry_get_device_material, crypto_registry_set_device_material } from "./registry.js"; +import { is_error } from "../utils/resolve.js"; const DEVICE_PROVIDER_ID = "device"; const DEVICE_MATERIAL_BYTES = 32; @@ -11,10 +12,12 @@ export class DeviceKeyMaterialProvider implements IDeviceKeyMaterialProvider { public async get_key_material(): Promise<Uint8Array> { if (!globalThis.crypto) throw new Error(cl_crypto_error.crypto_undefined); const existing = await crypto_registry_get_device_material(); + if (is_error(existing)) throw new Error(existing.err); if (existing) return new Uint8Array(existing); const material = new Uint8Array(DEVICE_MATERIAL_BYTES); crypto.getRandomValues(material); - await crypto_registry_set_device_material(material); + const stored = await crypto_registry_set_device_material(material); + if (is_error(stored)) throw new Error(stored.err); return new Uint8Array(material); } diff --git a/client/src/crypto/registry.ts b/client/src/crypto/registry.ts @@ -1,7 +1,8 @@ import { createStore, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; -import type { IdbClientConfig } from "@radroots/utils"; +import { err_msg, handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; import { IDB_CONFIG_CRYPTO_REGISTRY } from "../idb/config.js"; import { idb_store_ensure } from "../idb/store.js"; +import { is_error } from "../utils/resolve.js"; import { cl_crypto_error } from "./error.js"; import type { CryptoKeyEntry, CryptoRegistryExport, CryptoStoreIndex } from "./types.js"; @@ -12,13 +13,19 @@ const STORE_INDEX_PREFIX = "store:"; const KEY_ENTRY_PREFIX = "key:"; const DEVICE_MATERIAL_KEY = "device:material"; -const ensure_idb = async (): Promise<void> => { - if (typeof indexedDB === "undefined") throw new Error(cl_crypto_error.idb_undefined); - await idb_store_ensure(CRYPTO_IDB_CONFIG.database, CRYPTO_IDB_CONFIG.store); +const ensure_idb = async (): Promise<ResolveError<void>> => { + if (typeof indexedDB === "undefined") return err_msg(cl_crypto_error.idb_undefined); + try { + await idb_store_ensure(CRYPTO_IDB_CONFIG.database, CRYPTO_IDB_CONFIG.store); + return; + } catch (e) { + return handle_err(e); + } }; -const get_crypto_store = async (): Promise<UseStore> => { - await ensure_idb(); +const get_crypto_store = async (): Promise<ResolveError<UseStore>> => { + const ensured = await ensure_idb(); + if (is_error(ensured)) return ensured; if (!crypto_store) crypto_store = createStore(CRYPTO_IDB_CONFIG.database, CRYPTO_IDB_CONFIG.store); return crypto_store; }; @@ -55,93 +62,158 @@ const is_crypto_key_entry = (value: unknown): value is CryptoKeyEntry => { && typeof value.provider_id === "string"; }; -export const crypto_registry_get_store_index = async (store_id: string): Promise<CryptoStoreIndex | null> => { - const store = await get_crypto_store(); - const record = await idb_get(store_index_key(store_id), store); - if (!record) return null; - if (!is_crypto_store_index(record)) throw new Error(cl_crypto_error.registry_failure); - return record; +export const crypto_registry_get_store_index = async (store_id: string): Promise<ResolveError<CryptoStoreIndex | null>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + const record = await idb_get(store_index_key(store_id), store); + if (!record) return null; + if (!is_crypto_store_index(record)) return err_msg(cl_crypto_error.registry_failure); + return record; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_set_store_index = async (index: CryptoStoreIndex): Promise<void> => { - const store = await get_crypto_store(); - await idb_set(store_index_key(index.store_id), index, store); +export const crypto_registry_set_store_index = async (index: CryptoStoreIndex): Promise<ResolveError<void>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + await idb_set(store_index_key(index.store_id), index, store); + return; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_get_key_entry = async (key_id: string): Promise<CryptoKeyEntry | null> => { - const store = await get_crypto_store(); - const record = await idb_get(key_entry_key(key_id), store); - if (!record) return null; - if (!is_crypto_key_entry(record)) throw new Error(cl_crypto_error.registry_failure); - return record; +export const crypto_registry_get_key_entry = async (key_id: string): Promise<ResolveError<CryptoKeyEntry | null>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + const record = await idb_get(key_entry_key(key_id), store); + if (!record) return null; + if (!is_crypto_key_entry(record)) return err_msg(cl_crypto_error.registry_failure); + return record; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_set_key_entry = async (entry: CryptoKeyEntry): Promise<void> => { - const store = await get_crypto_store(); - await idb_set(key_entry_key(entry.key_id), entry, store); +export const crypto_registry_set_key_entry = async (entry: CryptoKeyEntry): Promise<ResolveError<void>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + await idb_set(key_entry_key(entry.key_id), entry, store); + return; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_list_store_indices = async (): Promise<CryptoStoreIndex[]> => { - const store = await get_crypto_store(); - const keys = await idb_keys(store); - const store_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(STORE_INDEX_PREFIX)); - const out: CryptoStoreIndex[] = []; - for (const key of store_keys) { - const record = await idb_get(key, store); - if (!record) continue; - if (!is_crypto_store_index(record)) throw new Error(cl_crypto_error.registry_failure); - out.push(record); +export const crypto_registry_list_store_indices = async (): Promise<ResolveError<CryptoStoreIndex[]>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + const keys = await idb_keys(store); + const store_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(STORE_INDEX_PREFIX)); + const out: CryptoStoreIndex[] = []; + for (const key of store_keys) { + const record = await idb_get(key, store); + if (!record) continue; + if (!is_crypto_store_index(record)) return err_msg(cl_crypto_error.registry_failure); + out.push(record); + } + return out; + } catch (e) { + return handle_err(e); } - return out; }; -export const crypto_registry_list_key_entries = async (): Promise<CryptoKeyEntry[]> => { - const store = await get_crypto_store(); - const keys = await idb_keys(store); - const entry_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(KEY_ENTRY_PREFIX)); - const out: CryptoKeyEntry[] = []; - for (const key of entry_keys) { - const record = await idb_get(key, store); - if (!record) continue; - if (!is_crypto_key_entry(record)) throw new Error(cl_crypto_error.registry_failure); - out.push(record); +export const crypto_registry_list_key_entries = async (): Promise<ResolveError<CryptoKeyEntry[]>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + const keys = await idb_keys(store); + const entry_keys = keys.filter((key): key is string => typeof key === "string" && key.startsWith(KEY_ENTRY_PREFIX)); + const out: CryptoKeyEntry[] = []; + for (const key of entry_keys) { + const record = await idb_get(key, store); + if (!record) continue; + if (!is_crypto_key_entry(record)) return err_msg(cl_crypto_error.registry_failure); + out.push(record); + } + return out; + } catch (e) { + return handle_err(e); } - return out; }; -export const crypto_registry_export = async (): Promise<CryptoRegistryExport> => { +export const crypto_registry_export = async (): Promise<ResolveError<CryptoRegistryExport>> => { const stores = await crypto_registry_list_store_indices(); + if (is_error(stores)) return stores; const keys = await crypto_registry_list_key_entries(); + if (is_error(keys)) return keys; return { stores, keys }; }; -export const crypto_registry_import = async (registry: CryptoRegistryExport): Promise<void> => { - await get_crypto_store(); - for (const store of registry.stores) await crypto_registry_set_store_index(store); - for (const entry of registry.keys) await crypto_registry_set_key_entry(entry); -}; - -export const crypto_registry_get_device_material = async (): Promise<Uint8Array | null> => { +export const crypto_registry_import = async (registry: CryptoRegistryExport): Promise<ResolveError<void>> => { const store = await get_crypto_store(); - const record = await idb_get(DEVICE_MATERIAL_KEY, store); - if (!record) return null; - if (record instanceof Uint8Array) return record; - if (record instanceof ArrayBuffer) return new Uint8Array(record); - if (ArrayBuffer.isView(record)) return new Uint8Array(record.buffer, record.byteOffset, record.byteLength); - throw new Error(cl_crypto_error.registry_failure); + if (is_error(store)) return store; + for (const store_index of registry.stores) { + const res = await crypto_registry_set_store_index(store_index); + if (is_error(res)) return res; + } + for (const entry of registry.keys) { + const res = await crypto_registry_set_key_entry(entry); + if (is_error(res)) return res; + } + return; +}; + +export const crypto_registry_get_device_material = async (): Promise<ResolveError<Uint8Array | null>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + const record = await idb_get(DEVICE_MATERIAL_KEY, store); + if (!record) return null; + if (record instanceof Uint8Array) return record; + if (record instanceof ArrayBuffer) return new Uint8Array(record); + if (ArrayBuffer.isView(record)) return new Uint8Array(record.buffer, record.byteOffset, record.byteLength); + return err_msg(cl_crypto_error.registry_failure); + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_set_device_material = async (material: Uint8Array): Promise<void> => { - const store = await get_crypto_store(); - await idb_set(DEVICE_MATERIAL_KEY, material, store); +export const crypto_registry_set_device_material = async (material: Uint8Array): Promise<ResolveError<void>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + await idb_set(DEVICE_MATERIAL_KEY, material, store); + return; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_clear_store_index = async (store_id: string): Promise<void> => { - const store = await get_crypto_store(); - await idb_del(store_index_key(store_id), store); +export const crypto_registry_clear_store_index = async (store_id: string): Promise<ResolveError<void>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + await idb_del(store_index_key(store_id), store); + return; + } catch (e) { + return handle_err(e); + } }; -export const crypto_registry_clear_key_entry = async (key_id: string): Promise<void> => { - const store = await get_crypto_store(); - await idb_del(key_entry_key(key_id), store); +export const crypto_registry_clear_key_entry = async (key_id: string): Promise<ResolveError<void>> => { + try { + const store = await get_crypto_store(); + if (is_error(store)) return store; + await idb_del(key_entry_key(key_id), store); + return; + } catch (e) { + return handle_err(e); + } }; diff --git a/client/src/crypto/service.ts b/client/src/crypto/service.ts @@ -1,6 +1,8 @@ import { createStore, get as idb_get } from "idb-keyval"; -import { as_array_buffer } from "@radroots/utils"; -import { idb_store_ensure } from "../idb/store.js"; +import { as_array_buffer, err_msg, handle_err, type ResolveError } from "@radroots/utils"; +import { idb_store_ensure, idb_store_exists } from "../idb/store.js"; +import { idb_value_as_bytes } from "../idb/value.js"; +import { is_error } from "../utils/resolve.js"; import { cl_crypto_error } from "./error.js"; import { crypto_envelope_decode, crypto_envelope_encode } from "./envelope.js"; import { crypto_kdf_derive_kek, crypto_kdf_iterations_default, crypto_kdf_salt_create } from "./kdf.js"; @@ -11,8 +13,9 @@ import type { CryptoDecryptOutcome, CryptoKeyEntry, CryptoRegistryExport, Crypto const DEFAULT_IV_LENGTH = 12; -const ensure_crypto = (): void => { - if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_crypto_error.crypto_undefined); +const ensure_crypto = (): ResolveError<void> => { + if (!globalThis.crypto || !globalThis.crypto.subtle) return err_msg(cl_crypto_error.crypto_undefined); + return; }; const merge_key_ids = (key_ids: string[], next_key_id: string): string[] => { @@ -20,12 +23,6 @@ const merge_key_ids = (key_ids: string[], next_key_id: string): string[] => { return [...key_ids, next_key_id]; }; -const bytes_from_value = (value: ArrayBuffer | ArrayBufferView): Uint8Array => { - if (value instanceof Uint8Array) return value; - if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - return new Uint8Array(value); -}; - export class WebCryptoService implements IWebCryptoService { private store_configs: Map<string, CryptoStoreConfig>; private key_material_provider: KeyMaterialProvider; @@ -33,7 +30,6 @@ export class WebCryptoService implements IWebCryptoService { constructor(config?: { key_material_provider?: KeyMaterialProvider }) { this.store_configs = new Map(); this.key_material_provider = config?.key_material_provider ?? new DeviceKeyMaterialProvider(); - ensure_crypto(); } public register_store_config(config: CryptoStoreConfig): void { @@ -53,9 +49,12 @@ export class WebCryptoService implements IWebCryptoService { }); } - public async encrypt(store_id: string, plaintext: Uint8Array): Promise<Uint8Array> { - ensure_crypto(); - const { key, entry } = await this.resolve_active_key(store_id); + public async encrypt(store_id: string, plaintext: Uint8Array): Promise<ResolveError<Uint8Array>> { + const env_err = ensure_crypto(); + if (env_err) return env_err; + const resolved = await this.resolve_active_key(store_id); + if (is_error(resolved)) return resolved; + const { key, entry } = resolved; const iv_length = entry.iv_length || DEFAULT_IV_LENGTH; const iv = new Uint8Array(iv_length); crypto.getRandomValues(iv); @@ -77,54 +76,67 @@ export class WebCryptoService implements IWebCryptoService { }; return crypto_envelope_encode(envelope); } catch { - throw new Error(cl_crypto_error.encrypt_failure); + return err_msg(cl_crypto_error.encrypt_failure); } } - public async decrypt(store_id: string, blob: Uint8Array): Promise<Uint8Array> { + public async decrypt(store_id: string, blob: Uint8Array): Promise<ResolveError<Uint8Array>> { const outcome = await this.decrypt_record(store_id, blob); + if (is_error(outcome)) return outcome; return outcome.plaintext; } - public async decrypt_record(store_id: string, blob: Uint8Array): Promise<CryptoDecryptOutcome> { - ensure_crypto(); + public async decrypt_record(store_id: string, blob: Uint8Array): Promise<ResolveError<CryptoDecryptOutcome>> { + const env_err = ensure_crypto(); + if (env_err) return env_err; const config = this.resolve_store_config(store_id); - const envelope = crypto_envelope_decode(blob); + let envelope: ReturnType<typeof crypto_envelope_decode>; + try { + envelope = crypto_envelope_decode(blob); + } catch (e) { + return handle_err(e); + } if (envelope) return await this.decrypt_envelope(store_id, envelope); return await this.decrypt_legacy(store_id, blob, config.legacy_key, config.iv_length ?? DEFAULT_IV_LENGTH); } - public async rotate_store_key(store_id: string): Promise<string> { + public async rotate_store_key(store_id: string): Promise<ResolveError<string>> { const config = this.resolve_store_config(store_id); const index = await crypto_registry_get_store_index(store_id); + if (is_error(index)) return index; if (!index) { const created = await this.create_store_key(store_id, config); + if (is_error(created)) return created; return created.entry.key_id; } const prev_entry = await crypto_registry_get_key_entry(index.active_key_id); + if (is_error(prev_entry)) return prev_entry; if (prev_entry) { const rotated_entry: CryptoKeyEntry = { ...prev_entry, status: "rotated" }; - await crypto_registry_set_key_entry(rotated_entry); + const set_entry = await crypto_registry_set_key_entry(rotated_entry); + if (is_error(set_entry)) return set_entry; } const created = await this.create_key_entry(store_id, config); + if (is_error(created)) return created; const next_index: CryptoStoreIndex = { ...index, active_key_id: created.entry.key_id, key_ids: merge_key_ids(index.key_ids, created.entry.key_id) }; - await crypto_registry_set_store_index(next_index); + const set_index = await crypto_registry_set_store_index(next_index); + if (is_error(set_index)) return set_index; return created.entry.key_id; } - public async export_registry(): Promise<CryptoRegistryExport> { + public async export_registry(): Promise<ResolveError<CryptoRegistryExport>> { return await crypto_registry_export(); } - public async import_registry(registry: CryptoRegistryExport): Promise<void> { - await crypto_registry_import(registry); + public async import_registry(registry: CryptoRegistryExport): Promise<ResolveError<void>> { + return await crypto_registry_import(registry); } private resolve_store_config(store_id: string): CryptoStoreConfig { @@ -138,19 +150,24 @@ export class WebCryptoService implements IWebCryptoService { return config; } - private async resolve_active_key(store_id: string): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> { + private async resolve_active_key(store_id: string): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { const index = await crypto_registry_get_store_index(store_id); + if (is_error(index)) return index; if (!index) return await this.create_store_key(store_id, this.resolve_store_config(store_id)); const entry = await crypto_registry_get_key_entry(index.active_key_id); + if (is_error(entry)) return entry; if (!entry) return await this.create_store_key(store_id, this.resolve_store_config(store_id)); const key = await this.unwrap_key_entry(entry); + if (is_error(key)) return key; return { key, entry, index }; } - private async resolve_key_by_id(store_id: string, key_id: string): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> { + private async resolve_key_by_id(store_id: string, key_id: string): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { const entry = await crypto_registry_get_key_entry(key_id); - if (!entry) throw new Error(cl_crypto_error.key_not_found); + if (is_error(entry)) return entry; + if (!entry) return err_msg(cl_crypto_error.key_not_found); let index = await crypto_registry_get_store_index(store_id); + if (is_error(index)) return index; if (!index) { index = { store_id, @@ -158,21 +175,25 @@ export class WebCryptoService implements IWebCryptoService { key_ids: [entry.key_id], created_at: entry.created_at }; - await crypto_registry_set_store_index(index); + const set_index = await crypto_registry_set_store_index(index); + if (is_error(set_index)) return set_index; } const key = await this.unwrap_key_entry(entry); + if (is_error(key)) return key; return { key, entry, index }; } - private async create_store_key(store_id: string, config: CryptoStoreConfig): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }> { + private async create_store_key(store_id: string, config: CryptoStoreConfig): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; index: CryptoStoreIndex; }>> { const created = await this.create_key_entry(store_id, config); + if (is_error(created)) return created; const index: CryptoStoreIndex = { store_id, active_key_id: created.entry.key_id, key_ids: [created.entry.key_id], created_at: created.entry.created_at }; - await crypto_registry_set_store_index(index); + const set_index = await crypto_registry_set_store_index(index); + if (is_error(set_index)) return set_index; return { key: created.key, entry: created.entry, @@ -180,44 +201,54 @@ export class WebCryptoService implements IWebCryptoService { }; } - private async create_key_entry(store_id: string, config: CryptoStoreConfig): Promise<{ key: CryptoKey; entry: CryptoKeyEntry; }> { - const key_id = crypto_key_id_create(); - const created_at = Date.now(); - const kdf_salt = crypto_kdf_salt_create(); - const kdf_iterations = crypto_kdf_iterations_default(); - const material = await this.key_material_provider.get_key_material(); - const provider_id = await this.key_material_provider.get_provider_id(); - const kek = await crypto_kdf_derive_kek(material, kdf_salt, kdf_iterations); - material.fill(0); - const data_key = await crypto_key_generate(); - const raw_key = await crypto_key_export_raw(data_key); - const wrapped = await crypto_key_wrap(kek, raw_key); - const entry: CryptoKeyEntry = { - key_id, - store_id, - created_at, - status: "active", - wrapped_key: wrapped.wrapped_key, - wrap_iv: wrapped.wrap_iv, - kdf_salt, - kdf_iterations, - iv_length: config.iv_length ?? DEFAULT_IV_LENGTH, - algorithm: "AES-GCM", - provider_id - }; - await crypto_registry_set_key_entry(entry); - return { key: data_key, entry }; + private async create_key_entry(store_id: string, config: CryptoStoreConfig): Promise<ResolveError<{ key: CryptoKey; entry: CryptoKeyEntry; }>> { + try { + const key_id = crypto_key_id_create(); + const created_at = Date.now(); + const kdf_salt = crypto_kdf_salt_create(); + const kdf_iterations = crypto_kdf_iterations_default(); + const material = await this.key_material_provider.get_key_material(); + const provider_id = await this.key_material_provider.get_provider_id(); + const kek = await crypto_kdf_derive_kek(material, kdf_salt, kdf_iterations); + material.fill(0); + const data_key = await crypto_key_generate(); + const raw_key = await crypto_key_export_raw(data_key); + const wrapped = await crypto_key_wrap(kek, raw_key); + const entry: CryptoKeyEntry = { + key_id, + store_id, + created_at, + status: "active", + wrapped_key: wrapped.wrapped_key, + wrap_iv: wrapped.wrap_iv, + kdf_salt, + kdf_iterations, + iv_length: config.iv_length ?? DEFAULT_IV_LENGTH, + algorithm: "AES-GCM", + provider_id + }; + const set_entry = await crypto_registry_set_key_entry(entry); + if (is_error(set_entry)) return set_entry; + return { key: data_key, entry }; + } catch (e) { + return handle_err(e); + } } - private async unwrap_key_entry(entry: CryptoKeyEntry): Promise<CryptoKey> { - const material = await this.key_material_provider.get_key_material(); - const kek = await crypto_kdf_derive_kek(material, entry.kdf_salt, entry.kdf_iterations); - material.fill(0); - return await crypto_key_unwrap(kek, entry.wrapped_key, entry.wrap_iv); + private async unwrap_key_entry(entry: CryptoKeyEntry): Promise<ResolveError<CryptoKey>> { + try { + const material = await this.key_material_provider.get_key_material(); + const kek = await crypto_kdf_derive_kek(material, entry.kdf_salt, entry.kdf_iterations); + material.fill(0); + return await crypto_key_unwrap(kek, entry.wrapped_key, entry.wrap_iv); + } catch (e) { + return handle_err(e); + } } - private async decrypt_envelope(store_id: string, envelope: { key_id: string; iv: Uint8Array; ciphertext: Uint8Array; }): Promise<CryptoDecryptOutcome> { + private async decrypt_envelope(store_id: string, envelope: { key_id: string; iv: Uint8Array; ciphertext: Uint8Array; }): Promise<ResolveError<CryptoDecryptOutcome>> { const resolved = await this.resolve_key_by_id(store_id, envelope.key_id); + if (is_error(resolved)) return resolved; try { const plain_buf = await crypto.subtle.decrypt( { @@ -231,9 +262,10 @@ export class WebCryptoService implements IWebCryptoService { const needs_reencrypt = resolved.index.active_key_id !== envelope.key_id; if (!needs_reencrypt) return { plaintext, needs_reencrypt }; const reencrypted = await this.encrypt(store_id, plaintext); + if (is_error(reencrypted)) return reencrypted; return { plaintext, needs_reencrypt, reencrypted }; } catch { - throw new Error(cl_crypto_error.decrypt_failure); + return err_msg(cl_crypto_error.decrypt_failure); } } @@ -242,13 +274,14 @@ export class WebCryptoService implements IWebCryptoService { blob: Uint8Array, legacy_key: LegacyKeyConfig | undefined, iv_length: number - ): Promise<CryptoDecryptOutcome> { - if (!legacy_key) throw new Error(cl_crypto_error.legacy_key_missing); + ): Promise<ResolveError<CryptoDecryptOutcome>> { + if (!legacy_key) return err_msg(cl_crypto_error.legacy_key_missing); const legacy_crypto_key = await this.load_legacy_key(legacy_key); - if (!legacy_crypto_key) throw new Error(cl_crypto_error.legacy_key_missing); - if (blob.byteLength <= iv_length) throw new Error(cl_crypto_error.invalid_envelope); - const iv = blob.slice(0, iv_length); - const ciphertext = blob.slice(iv_length); + if (is_error(legacy_crypto_key)) return legacy_crypto_key; + if (!legacy_crypto_key) return err_msg(cl_crypto_error.legacy_key_missing); + if (blob.byteLength <= iv_length) return err_msg(cl_crypto_error.invalid_envelope); + const iv = blob.subarray(0, iv_length); + const ciphertext = blob.subarray(iv_length); try { const plain_buf = await crypto.subtle.decrypt( { @@ -260,22 +293,28 @@ export class WebCryptoService implements IWebCryptoService { ); const plaintext = new Uint8Array(plain_buf); const reencrypted = await this.encrypt(store_id, plaintext); + if (is_error(reencrypted)) return reencrypted; return { plaintext, needs_reencrypt: true, reencrypted }; } catch { - throw new Error(cl_crypto_error.decrypt_failure); + return err_msg(cl_crypto_error.decrypt_failure); } } - private async load_legacy_key(legacy: LegacyKeyConfig): Promise<CryptoKey | null> { - if (typeof indexedDB === "undefined") return null; - await idb_store_ensure(legacy.idb_config.database, legacy.idb_config.store); - const legacy_store = createStore(legacy.idb_config.database, legacy.idb_config.store); - const stored = await idb_get(legacy.key_name, legacy_store); - if (!stored) return null; - if (stored instanceof CryptoKey) return stored; - if (stored instanceof Uint8Array) return await crypto_key_import_raw(stored); - if (stored instanceof ArrayBuffer) return await crypto_key_import_raw(new Uint8Array(stored)); - if (ArrayBuffer.isView(stored)) return await crypto_key_import_raw(bytes_from_value(stored)); - return null; + private async load_legacy_key(legacy: LegacyKeyConfig): Promise<ResolveError<CryptoKey | null>> { + if (typeof indexedDB === "undefined") return err_msg(cl_crypto_error.idb_undefined); + const exists = await idb_store_exists(legacy.idb_config.database, legacy.idb_config.store); + if (!exists) return null; + try { + await idb_store_ensure(legacy.idb_config.database, legacy.idb_config.store); + const legacy_store = createStore(legacy.idb_config.database, legacy.idb_config.store); + const stored = await idb_get(legacy.key_name, legacy_store); + if (!stored) return null; + if (stored instanceof CryptoKey) return stored; + const bytes = idb_value_as_bytes(stored); + if (!bytes) return null; + return await crypto_key_import_raw(bytes); + } catch (e) { + return handle_err(e); + } } } diff --git a/client/src/crypto/types.ts b/client/src/crypto/types.ts @@ -1,4 +1,4 @@ -import type { IdbClientConfig } from "@radroots/utils"; +import type { IdbClientConfig, ResolveError } from "@radroots/utils"; export type CryptoKeyStatus = "active" | "rotated"; @@ -62,10 +62,10 @@ export interface KeyMaterialProvider { export interface IWebCryptoService { register_store_config(config: CryptoStoreConfig): void; - encrypt(store_id: string, plaintext: Uint8Array): Promise<Uint8Array>; - decrypt(store_id: string, blob: Uint8Array): Promise<Uint8Array>; - decrypt_record(store_id: string, blob: Uint8Array): Promise<CryptoDecryptOutcome>; - rotate_store_key(store_id: string): Promise<string>; - export_registry(): Promise<CryptoRegistryExport>; - import_registry(registry: CryptoRegistryExport): Promise<void>; + encrypt(store_id: string, plaintext: Uint8Array): Promise<ResolveError<Uint8Array>>; + decrypt(store_id: string, blob: Uint8Array): Promise<ResolveError<Uint8Array>>; + decrypt_record(store_id: string, blob: Uint8Array): Promise<ResolveError<CryptoDecryptOutcome>>; + rotate_store_key(store_id: string): Promise<ResolveError<string>>; + export_registry(): Promise<ResolveError<CryptoRegistryExport>>; + import_registry(registry: CryptoRegistryExport): Promise<ResolveError<void>>; } diff --git a/client/src/datastore/web.ts b/client/src/datastore/web.ts @@ -1,10 +1,11 @@ import { err_msg, handle_err, text_dec, text_enc, type IdbClientConfig, type ResolveError, type ResultObj, type ResultPass, type ResultsList } from "@radroots/utils"; -import { createStore, clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; +import { clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; import type { BackupDatastorePayload } from "../backup/types.js"; -import { WebCryptoService } from "../crypto/service.js"; import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js"; +import { WebEncryptedStore } from "../idb/encrypted_store.js"; import { IDB_CONFIG_DATASTORE } from "../idb/config.js"; -import { idb_store_ensure } from "../idb/store.js"; +import { idb_value_as_bytes } from "../idb/value.js"; +import { is_error } from "../utils/resolve.js"; import { cl_datastore_error } from "./error.js"; import type { IClientDatastore, @@ -30,68 +31,56 @@ export class WebDatastore< Tp extends IClientDatastoreKeyParamMap, TkO extends IClientDatastoreKeyMap, > implements IWebDatastore<Tk, Tp, TkO> { - private db_name: string; - private store_name: string; - private store: UseStore | null = null; - private store_ready: Promise<void> | null = null; + private readonly encrypted_store: WebEncryptedStore; + private readonly store_id: string; private _key_map: Tk; private _key_param_map: Tp; private _key_obj_map: TkO; - private crypto: WebCryptoService; - private store_id: string; constructor(key_map: Tk, key_param_map: Tp, key_obj_map: TkO, config?: Partial<IdbClientConfig>) { - this.db_name = config?.database ?? DEFAULT_IDB_CONFIG.database; - this.store_name = config?.store ?? DEFAULT_IDB_CONFIG.store; - this.store = null; - this._key_map = key_map; - this._key_param_map = key_param_map; - this._key_obj_map = key_obj_map; - this.store_id = `datastore:${this.db_name}:${this.store_name}`; - this.crypto = new WebCryptoService(); - this.crypto.register_store_config({ + const idb_config: IdbClientConfig = { + database: config?.database ?? DEFAULT_IDB_CONFIG.database, + store: config?.store ?? DEFAULT_IDB_CONFIG.store + }; + this.store_id = `datastore:${idb_config.database}:${idb_config.store}`; + this.encrypted_store = new WebEncryptedStore({ + idb_config, store_id: this.store_id, + idb_error: cl_datastore_error.idb_undefined, iv_length: 12 }); + this._key_map = key_map; + this._key_param_map = key_param_map; + this._key_obj_map = key_obj_map; } - private async get_store(): Promise<UseStore> { - if (typeof indexedDB === "undefined") throw new Error(cl_datastore_error.idb_undefined); - if (!this.store_ready) this.store_ready = idb_store_ensure(this.db_name, this.store_name); - await this.store_ready; - if (!this.store) this.store = createStore(this.db_name, this.store_name); - return this.store; - } - - private as_bytes(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) return value; - if (value instanceof ArrayBuffer) return new Uint8Array(value); - if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - return null; + private async get_store(): Promise<ResolveError<UseStore>> { + return await this.encrypted_store.get_store(); } private async decrypt_value(store_key: string, stored: unknown): Promise<ResolveError<ResultObj<string>>> { if (typeof stored === "string") { - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(stored)); + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(stored)); + if (is_error(encrypted)) return encrypted; const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(store_key, encrypted, store); return { result: stored }; } - const bytes = this.as_bytes(stored); + const bytes = idb_value_as_bytes(stored); if (!bytes) return err_msg(cl_datastore_error.no_result); - const outcome = await this.crypto.decrypt_record(this.store_id, bytes); + const outcome = await this.encrypted_store.decrypt_record(bytes); + if (is_error(outcome)) return outcome; if (outcome.reencrypted) { const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(store_key, outcome.reencrypted, store); } return { result: text_dec(outcome.plaintext) }; } public get_config(): IdbClientConfig { - return { - database: this.db_name, - store: this.store_name, - }; + return this.encrypted_store.get_config(); } public get_store_id(): string { @@ -100,7 +89,9 @@ export class WebDatastore< public async init(): Promise<ResolveError<void>> { try { - await this.get_store(); + const store = await this.get_store(); + if (is_error(store)) return store; + return; } catch (e) { return handle_err(e); } @@ -108,8 +99,10 @@ export class WebDatastore< public async set(key: keyof Tk, value: string): Promise<ResolveError<ResultObj<string>>> { try { - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(value)); + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(value)); + if (is_error(encrypted)) return encrypted; const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(this._key_map[key], encrypted, store); return { result: value }; } catch (e) { @@ -121,6 +114,7 @@ export class WebDatastore< try { const store_key = this._key_map[key]; const store = await this.get_store(); + if (is_error(store)) return store; const value = await idb_get(store_key, store); if (!value) return err_msg(cl_datastore_error.no_result); return await this.decrypt_value(store_key, value); @@ -132,6 +126,7 @@ export class WebDatastore< public async del(key: keyof Tk): Promise<IClientDatastoreDelResolve> { try { const store = await this.get_store(); + if (is_error(store)) return store; await idb_del(this._key_map[key], store); return { result: key.toString() }; } catch (e) { @@ -142,8 +137,10 @@ export class WebDatastore< public async set_obj<T>(key: keyof TkO, value: T): Promise<ResolveError<ResultObj<T>>> { try { const serialized = JSON.stringify(value); - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(serialized)); + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(serialized)); + if (is_error(encrypted)) return encrypted; const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(this._key_obj_map[key], encrypted, store); return { result: value }; } catch (e) { @@ -154,18 +151,20 @@ export class WebDatastore< public async update_obj<T extends Record<string, unknown>>(key: keyof TkO, value: Partial<T>): Promise<ResolveError<ResultObj<T>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; const k = this._key_obj_map[key]; const obj_curr: Record<string, unknown> = {}; const curr = await idb_get(k, store); if (curr) { const decrypted = await this.decrypt_value(k, curr); - if ("err" in decrypted) return decrypted; + if (is_error(decrypted)) return decrypted; const parsed: unknown = JSON.parse(decrypted.result); if (is_record(parsed)) for (const [curr_key, curr_val] of Object.entries(parsed)) obj_curr[curr_key] = curr_val; } const obj: T = { ...obj_curr, ...value } as T; const serialized = JSON.stringify(obj); - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(serialized)); + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(serialized)); + if (is_error(encrypted)) return encrypted; await idb_set(k, encrypted, store); return { result: obj }; } catch (e) { @@ -177,10 +176,11 @@ export class WebDatastore< try { const store_key = this._key_obj_map[key]; const store = await this.get_store(); + if (is_error(store)) return store; const value = await idb_get(store_key, store); if (!value) return err_msg(cl_datastore_error.no_result); const decrypted = await this.decrypt_value(store_key, value); - if ("err" in decrypted) return decrypted; + if (is_error(decrypted)) return decrypted; return { result: JSON.parse(decrypted.result) }; } catch (e) { return handle_err(e); @@ -190,6 +190,7 @@ export class WebDatastore< public async del_obj(key: keyof TkO): Promise<ResolveError<ResultObj<string>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; await idb_del(this._key_obj_map[key], store); return { result: key.toString() }; } catch (e) { @@ -204,8 +205,10 @@ export class WebDatastore< ): Promise<ResolveError<ResultObj<string>>> { try { const store_key = this._key_param_map[key](key_param); - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(value)); + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(value)); + if (is_error(encrypted)) return encrypted; const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(store_key, encrypted, store); return { result: value }; } catch (e) { @@ -220,6 +223,7 @@ export class WebDatastore< try { const store_key = this._key_param_map[key](key_param); const store = await this.get_store(); + if (is_error(store)) return store; const value = await idb_get(store_key, store); if (!value) return err_msg(cl_datastore_error.no_result); return await this.decrypt_value(store_key, value); @@ -228,72 +232,108 @@ export class WebDatastore< } } - public async del_pref(key_prefix: string): Promise<IClientDatastoreDelPrefResolve> { + public async keys(): Promise<ResolveError<ResultsList<string>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; const all_keys = await idb_keys(store); - const filtered_keys = all_keys.filter((k): k is string => (typeof k === "string" && k.startsWith(key_prefix))); - for (const key of filtered_keys) { - await idb_del(key, store); - } - return { results: filtered_keys }; + return { results: all_keys.filter((k): k is string => typeof k === "string") }; } catch (e) { return handle_err(e); } } - public async keys(): Promise<ResolveError<ResultsList<string>>> { + public async del_pref(key_prefix: string): Promise<IClientDatastoreDelPrefResolve> { try { const store = await this.get_store(); + if (is_error(store)) return store; const all_keys = await idb_keys(store); - return { results: all_keys.filter((k): k is string => typeof k === "string") }; + const pref = all_keys.filter((k): k is string => typeof k === "string" && k.startsWith(key_prefix)); + for (const key of pref) await idb_del(key, store); + return { results: pref }; } catch (e) { return handle_err(e); } } - public async export_backup(): Promise<ResolveError<BackupDatastorePayload>> { + public async entries(): Promise<ResolveError<ResultsList<[string, string | null]>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; const all_keys = await idb_keys(store); - const entries: BackupDatastorePayload["entries"] = []; + const out: [string, string | null][] = []; for (const key of all_keys) { if (typeof key !== "string") continue; const value = await idb_get(key, store); - if (!value) continue; + if (!value) { + out.push([key, null]); + continue; + } + if (typeof value === "string") { + out.push([key, value]); + continue; + } const decrypted = await this.decrypt_value(key, value); - if ("err" in decrypted) return decrypted; - entries.push({ key, value: decrypted.result }); + if (is_error(decrypted)) return decrypted; + out.push([key, decrypted.result]); } - return { entries }; + return { results: out }; } catch (e) { return handle_err(e); } } - public async import_backup(payload: BackupDatastorePayload): Promise<ResolveError<void>> { + public async reset(): Promise<ResolveError<ResultPass>> { try { const store = await this.get_store(); - for (const entry of payload.entries) { - const encrypted = await this.crypto.encrypt(this.store_id, text_enc(entry.value)); - await idb_set(entry.key, encrypted, store); + if (is_error(store)) return store; + await idb_clear(store); + const index = await crypto_registry_get_store_index(this.store_id); + if (is_error(index)) return index; + if (index) { + const cleared = await crypto_registry_clear_store_index(this.store_id); + if (is_error(cleared)) return cleared; + for (const key_id of index.key_ids) { + const res = await crypto_registry_clear_key_entry(key_id); + if (is_error(res)) return res; + } } - return; + return { pass: true } as const; } catch (e) { return handle_err(e); } } - public async reset(): Promise<ResolveError<ResultPass>> { + public async export_backup(): Promise<ResolveError<BackupDatastorePayload>> { try { const store = await this.get_store(); - await idb_clear(store); - const index = await crypto_registry_get_store_index(this.store_id); - if (index) { - await crypto_registry_clear_store_index(this.store_id); - for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id); + if (is_error(store)) return store; + const all_keys = await idb_keys(store); + const entries: BackupDatastorePayload["entries"] = []; + for (const key of all_keys) { + if (typeof key !== "string") continue; + const stored = await idb_get(key, store); + if (!stored) return err_msg(cl_datastore_error.no_result); + const decrypted = await this.decrypt_value(key, stored); + if (is_error(decrypted)) return decrypted; + entries.push({ key, value: decrypted.result }); } - return { pass: true } as const; + return { entries }; + } catch (e) { + return handle_err(e); + } + } + + public async import_backup(payload: BackupDatastorePayload): Promise<ResolveError<void>> { + try { + const store = await this.get_store(); + if (is_error(store)) return store; + for (const entry of payload.entries) { + const encrypted = await this.encrypted_store.encrypt_bytes(text_enc(entry.value)); + if (is_error(encrypted)) return encrypted; + await idb_set(entry.key, encrypted, store); + } + return; } catch (e) { return handle_err(e); } diff --git a/client/src/idb/encrypted_store.ts b/client/src/idb/encrypted_store.ts @@ -0,0 +1,75 @@ +import { err_msg, handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; +import { createStore, type UseStore } from "idb-keyval"; +import type { CryptoDecryptOutcome, CryptoStoreConfig, IWebCryptoService, LegacyKeyConfig } from "../crypto/types.js"; +import { WebCryptoService } from "../crypto/service.js"; +import { idb_store_ensure } from "./store.js"; + +export type WebEncryptedStoreConfig = { + idb_config: IdbClientConfig; + store_id: string; + idb_error: string; + legacy_key?: LegacyKeyConfig | null; + iv_length?: number; + crypto_service?: IWebCryptoService; +}; + +export interface IWebEncryptedStore { + get_config(): IdbClientConfig; + get_store_id(): string; + get_store(): Promise<ResolveError<UseStore>>; + encrypt_bytes(bytes: Uint8Array): Promise<ResolveError<Uint8Array>>; + decrypt_record(blob: Uint8Array): Promise<ResolveError<CryptoDecryptOutcome>>; +} + +export class WebEncryptedStore implements IWebEncryptedStore { + private readonly config: IdbClientConfig; + private readonly store_id: string; + private readonly idb_error: string; + private readonly crypto: IWebCryptoService; + private store: UseStore | null = null; + private store_ready: Promise<void> | null = null; + + constructor(config: WebEncryptedStoreConfig) { + this.config = config.idb_config; + this.store_id = config.store_id; + this.idb_error = config.idb_error; + this.crypto = config.crypto_service ?? new WebCryptoService(); + const store_config: CryptoStoreConfig = { + store_id: this.store_id, + iv_length: config.iv_length + }; + if (config.legacy_key) store_config.legacy_key = config.legacy_key; + this.crypto.register_store_config(store_config); + } + + public get_config(): IdbClientConfig { + return { + database: this.config.database, + store: this.config.store + }; + } + + public get_store_id(): string { + return this.store_id; + } + + public async get_store(): Promise<ResolveError<UseStore>> { + if (typeof indexedDB === "undefined") return err_msg(this.idb_error); + try { + if (!this.store_ready) this.store_ready = idb_store_ensure(this.config.database, this.config.store); + await this.store_ready; + if (!this.store) this.store = createStore(this.config.database, this.config.store); + return this.store; + } catch (e) { + return handle_err(e); + } + } + + public async encrypt_bytes(bytes: Uint8Array): Promise<ResolveError<Uint8Array>> { + return await this.crypto.encrypt(this.store_id, bytes); + } + + public async decrypt_record(blob: Uint8Array): Promise<ResolveError<CryptoDecryptOutcome>> { + return await this.crypto.decrypt_record(this.store_id, blob); + } +} diff --git a/client/src/idb/store.ts b/client/src/idb/store.ts @@ -25,6 +25,18 @@ const RADROOTS_IDB_STORES = [ const idb_missing_stores = (db: IDBDatabase, stores: string[]): string[] => stores.filter((store) => !db.objectStoreNames.contains(store)); +const idb_database_exists = async (database: string): Promise<boolean> => { + if (typeof indexedDB === "undefined") return false; + const list_fn = indexedDB.databases; + if (typeof list_fn !== "function") return true; + try { + const entries = await list_fn.call(indexedDB); + return entries.some((entry) => entry.name === database); + } catch { + return true; + } +}; + const idb_open = (database: string, version?: number, stores?: string[]): Promise<IDBDatabase> => new Promise((resolve, reject) => { const request = indexedDB.open(database, version); @@ -42,11 +54,9 @@ const idb_open = (database: string, version?: number, stores?: string[]): Promis }; }); -export const idb_store_ensure = async (database: string, store: string): Promise<void> => { - if (typeof indexedDB === "undefined") return; - const target_stores = database === RADROOTS_IDB_DATABASE - ? Array.from(new Set([...RADROOTS_IDB_STORES, store])) - : [store]; +const idb_store_ensure_all = async (database: string, stores: string[]): Promise<void> => { + if (stores.length === 0) return; + const target_stores = Array.from(new Set(stores)); let attempt = 0; while (attempt < 5) { attempt++; @@ -69,3 +79,29 @@ export const idb_store_ensure = async (database: string, store: string): Promise } } }; + +export const idb_store_ensure = async (database: string, store: string): Promise<void> => { + if (typeof indexedDB === "undefined") return; + await idb_store_ensure_all(database, [store]); +}; + +export const idb_store_bootstrap = async (database: string, stores?: string[]): Promise<void> => { + if (typeof indexedDB === "undefined") return; + const target_stores = stores ?? (database === RADROOTS_IDB_DATABASE ? RADROOTS_IDB_STORES : []); + if (target_stores.length === 0) return; + await idb_store_ensure_all(database, target_stores); +}; + +export const idb_store_exists = async (database: string, store: string): Promise<boolean> => { + if (typeof indexedDB === "undefined") return false; + const known = await idb_database_exists(database); + if (!known) return false; + try { + const db = await idb_open(database); + const exists = db.objectStoreNames.contains(store); + db.close(); + return exists; + } catch { + return false; + } +}; diff --git a/client/src/idb/value.ts b/client/src/idb/value.ts @@ -0,0 +1,6 @@ +export const idb_value_as_bytes = (value: unknown): Uint8Array | null => { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return null; +}; diff --git a/client/src/keystore/index.ts b/client/src/keystore/index.ts @@ -1,6 +1,4 @@ -export * from "../cipher/web.js"; export * from "./error.js"; export * from "./types.js"; export * from "./web-nostr.js"; export * from "./web.js"; - diff --git a/client/src/keystore/web.ts b/client/src/keystore/web.ts @@ -9,24 +9,17 @@ import { type ResultPass, type ResultsList } from "@radroots/utils"; -import { createStore, clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; +import { clear as idb_clear, del as idb_del, get as idb_get, keys as idb_keys, set as idb_set, type UseStore } from "idb-keyval"; import type { BackupKeystorePayload } from "../backup/types.js"; -import { WebCryptoService } from "../crypto/service.js"; -import type { LegacyKeyConfig } from "../crypto/types.js"; import { crypto_registry_clear_key_entry, crypto_registry_clear_store_index, crypto_registry_get_store_index } from "../crypto/registry.js"; +import type { LegacyKeyConfig } from "../crypto/types.js"; +import { WebEncryptedStore } from "../idb/encrypted_store.js"; import { IDB_CONFIG_KEYSTORE, IDB_STORE_CIPHER_SUFFIX } from "../idb/config.js"; -import { idb_store_ensure } from "../idb/store.js"; +import { idb_value_as_bytes } from "../idb/value.js"; +import { is_error } from "../utils/resolve.js"; import { cl_keystore_error } from "./error.js"; import type { IClientKeystore, IClientKeystoreValue } from "./types.js"; -export type WebAesGcmCipherConfig = { - idb_config?: Partial<IdbClientConfig>; - key_name?: string; - key_length?: number; - iv_length?: number; - algorithm?: string; -}; - export interface IWebKeystore extends IClientKeystore { get_config(): IdbClientConfig; get_store_id(): string; @@ -36,10 +29,8 @@ export interface IWebKeystore extends IClientKeystore { export class WebKeystore implements IWebKeystore { private config: IdbClientConfig; - private store: UseStore | null; - private store_ready: Promise<void> | null; - private crypto: WebCryptoService; private store_id: string; + private encrypted_store: WebEncryptedStore; private legacy_key_config: LegacyKeyConfig; constructor(config?: Partial<IdbClientConfig>) { @@ -47,10 +38,7 @@ export class WebKeystore implements IWebKeystore { database: config?.database ?? IDB_CONFIG_KEYSTORE.database, store: config?.store ?? IDB_CONFIG_KEYSTORE.store }; - this.store = null; - this.store_ready = null; this.store_id = `keystore:${this.config.database}:${this.config.store}`; - this.crypto = new WebCryptoService(); const legacy_store = `${this.config.store}${IDB_STORE_CIPHER_SUFFIX}`; this.legacy_key_config = { @@ -63,26 +51,17 @@ export class WebKeystore implements IWebKeystore { algorithm: "AES-GCM" }; - this.crypto.register_store_config({ + this.encrypted_store = new WebEncryptedStore({ + idb_config: this.config, store_id: this.store_id, + idb_error: cl_keystore_error.idb_undefined, legacy_key: this.legacy_key_config, iv_length: 12 }); } - private async get_store(): Promise<UseStore> { - if (typeof indexedDB === "undefined") throw new Error(cl_keystore_error.idb_undefined); - if (!this.store_ready) this.store_ready = idb_store_ensure(this.config.database, this.config.store); - await this.store_ready; - if (!this.store) this.store = createStore(this.config.database, this.config.store); - return this.store; - } - - private as_bytes(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) return value; - if (value instanceof ArrayBuffer) return new Uint8Array(value); - if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - return null; + private async get_store(): Promise<ResolveError<UseStore>> { + return await this.encrypted_store.get_store(); } public get_config(): IdbClientConfig { @@ -99,8 +78,10 @@ export class WebKeystore implements IWebKeystore { public async add(key: string, value: string): Promise<ResolveError<ResultObj<string>>> { try { const bytes = text_enc(value); - const cipher_bytes = await this.crypto.encrypt(this.store_id, bytes); + const cipher_bytes = await this.encrypted_store.encrypt_bytes(bytes); + if (is_error(cipher_bytes)) return cipher_bytes; const store = await this.get_store(); + if (is_error(store)) return store; await idb_set(key, cipher_bytes, store); return { result: key }; } catch (e) { @@ -111,6 +92,7 @@ export class WebKeystore implements IWebKeystore { public async remove(key: string): Promise<ResolveError<ResultObj<string>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; await idb_del(key, store); return { result: key }; } catch (e) { @@ -122,10 +104,12 @@ export class WebKeystore implements IWebKeystore { try { if (!key) return err_msg(cl_keystore_error.missing_key); const store = await this.get_store(); + if (is_error(store)) return store; const cipher_value = await idb_get(key, store); - const cipher_bytes = this.as_bytes(cipher_value); + const cipher_bytes = idb_value_as_bytes(cipher_value); if (!cipher_bytes) return err_msg(cl_keystore_error.corrupt_data); - const outcome = await this.crypto.decrypt_record(this.store_id, cipher_bytes); + const outcome = await this.encrypted_store.decrypt_record(cipher_bytes); + if (is_error(outcome)) return outcome; if (outcome.reencrypted) await idb_set(key, outcome.reencrypted, store); const plain = text_dec(outcome.plaintext); return { result: plain }; @@ -137,6 +121,7 @@ export class WebKeystore implements IWebKeystore { public async keys(): Promise<ResolveError<ResultsList<string>>> { try { const store = await this.get_store(); + if (is_error(store)) return store; const all_keys = await idb_keys(store); return { results: all_keys.filter((k): k is string => typeof k === "string") }; } catch (e) { @@ -147,12 +132,13 @@ export class WebKeystore implements IWebKeystore { public async export_backup(): Promise<ResolveError<BackupKeystorePayload>> { try { const store = await this.get_store(); + if (is_error(store)) return store; const all_keys = await idb_keys(store); const entries: BackupKeystorePayload["entries"] = []; for (const key of all_keys) { if (typeof key !== "string") continue; const value = await this.read(key); - if ("err" in value) return value; + if (is_error(value)) return value; if (typeof value.result !== "string") return err_msg(cl_keystore_error.corrupt_data); entries.push({ key, value: value.result }); } @@ -164,10 +150,11 @@ export class WebKeystore implements IWebKeystore { public async import_backup(payload: BackupKeystorePayload): Promise<ResolveError<void>> { try { - await this.get_store(); + const store = await this.get_store(); + if (is_error(store)) return store; for (const entry of payload.entries) { const res = await this.add(entry.key, entry.value); - if ("err" in res) return res; + if (is_error(res)) return res; } return; } catch (e) { @@ -178,11 +165,17 @@ export class WebKeystore implements IWebKeystore { public async reset(): Promise<ResolveError<ResultPass>> { try { const store = await this.get_store(); + if (is_error(store)) return store; await idb_clear(store); const index = await crypto_registry_get_store_index(this.store_id); + if (is_error(index)) return index; if (index) { - await crypto_registry_clear_store_index(this.store_id); - for (const key_id of index.key_ids) await crypto_registry_clear_key_entry(key_id); + const cleared = await crypto_registry_clear_store_index(this.store_id); + if (is_error(cleared)) return cleared; + for (const key_id of index.key_ids) { + const res = await crypto_registry_clear_key_entry(key_id); + if (is_error(res)) return res; + } } return { pass: true } as const; } catch (e) { diff --git a/client/src/sql/error.ts b/client/src/sql/error.ts @@ -1,4 +1,5 @@ export const cl_sql_error = { + idb_undefined: "error.client.sql.idb_undefined" } as const; export type ClientSqlError = keyof typeof cl_sql_error; diff --git a/client/src/sql/web.ts b/client/src/sql/web.ts @@ -1,16 +1,22 @@ import { handle_err, type IdbClientConfig, type ResolveError } from "@radroots/utils"; -import { createStore, del as idb_del, get as idb_get, set as idb_set, type UseStore } from "idb-keyval"; +import { del as idb_del, get as idb_get, set as idb_set, type UseStore } from "idb-keyval"; import type { BindParams, Database, SqlJsStatic, SqlValue, Statement } from "sql.js"; import init_sql_js from "sql.js/dist/sql-wasm.js"; import { backup_b64_to_bytes, backup_bytes_to_b64 } from "../backup/codec.js"; import type { BackupSqlPayload } from "../backup/types.js"; -import { WebCryptoService } from "../crypto/service.js"; import type { LegacyKeyConfig } from "../crypto/types.js"; import { IDB_CONFIG_CIPHER_SQL } from "../idb/config.js"; -import { idb_store_ensure } from "../idb/store.js"; +import { WebEncryptedStore } from "../idb/encrypted_store.js"; +import { idb_value_as_bytes } from "../idb/value.js"; +import { is_error } from "../utils/resolve.js"; +import { cl_sql_error } from "./error.js"; import type { IClientSqlEncryptedStore, IWebSqlEngine, SqlJsExecOutcome, SqlJsParams, SqlJsResultRow, WebSqlEngineConfig } from "./types.js"; const DEFAULT_SQL_CIPHER_CONFIG: IdbClientConfig = IDB_CONFIG_CIPHER_SQL; +const resolve_or_throw = <T>(value: ResolveError<T>): T => { + if (is_error(value)) throw new Error(value.err); + return value; +}; interface IWebSqlEngineEncryptedStore extends IClientSqlEncryptedStore { get_store_id(): string; @@ -19,29 +25,27 @@ interface IWebSqlEngineEncryptedStore extends IClientSqlEncryptedStore { class WebSqlEngineEncryptedStore implements IWebSqlEngineEncryptedStore { private readonly store_key: string; private readonly store_id: string; - private readonly crypto: WebCryptoService; - private readonly db_name: string; - private readonly store_name: string; - private store: UseStore | null; - private store_ready: Promise<void> | null; + private readonly encrypted_store: WebEncryptedStore; constructor(config: WebSqlEngineConfig) { this.store_key = config.store_key; - this.db_name = config.idb_config.database; - this.store_name = config.idb_config.store; - this.store = null; - this.store_ready = null; this.store_id = `sql:${this.store_key}`; - this.crypto = new WebCryptoService(); - const legacy_config: LegacyKeyConfig = { - idb_config: config.cipher_config ?? DEFAULT_SQL_CIPHER_CONFIG, - key_name: `radroots.sql.${this.store_key}.aes-gcm.key`, - iv_length: 12, - algorithm: "AES-GCM" - }; - this.crypto.register_store_config({ + const legacy_idb_config = config.cipher_config === null + ? null + : config.cipher_config ?? DEFAULT_SQL_CIPHER_CONFIG; + const legacy_key: LegacyKeyConfig | null = legacy_idb_config + ? { + idb_config: legacy_idb_config, + key_name: `radroots.sql.${this.store_key}.aes-gcm.key`, + iv_length: 12, + algorithm: "AES-GCM" + } + : null; + this.encrypted_store = new WebEncryptedStore({ + idb_config: config.idb_config, store_id: this.store_id, - legacy_key: legacy_config, + idb_error: cl_sql_error.idb_undefined, + legacy_key, iv_length: 12 }); } @@ -50,34 +54,25 @@ class WebSqlEngineEncryptedStore implements IWebSqlEngineEncryptedStore { return this.store_id; } - private as_bytes(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) return value; - if (value instanceof ArrayBuffer) return new Uint8Array(value); - if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - return null; - } - private async get_store(): Promise<UseStore> { - if (!this.store_ready) this.store_ready = idb_store_ensure(this.db_name, this.store_name); - await this.store_ready; - if (!this.store) this.store = createStore(this.db_name, this.store_name); - return this.store; + const store = await this.encrypted_store.get_store(); + return resolve_or_throw(store); } async load(): Promise<Uint8Array | null> { if (typeof indexedDB === "undefined") return null; const store = await this.get_store(); const data = await idb_get(this.store_key, store); - const bytes = this.as_bytes(data); + const bytes = idb_value_as_bytes(data); if (!bytes) return null; - const outcome = await this.crypto.decrypt_record(this.store_id, bytes); + const outcome = resolve_or_throw(await this.encrypted_store.decrypt_record(bytes)); if (outcome.reencrypted) await idb_set(this.store_key, outcome.reencrypted, store); return outcome.plaintext; } async save(bytes: Uint8Array): Promise<void> { if (typeof indexedDB === "undefined") return; - const enc = await this.crypto.encrypt(this.store_id, bytes); + const enc = resolve_or_throw(await this.encrypted_store.encrypt_bytes(bytes)); const store = await this.get_store(); await idb_set(this.store_key, enc, store); } diff --git a/client/src/tangle/types.ts b/client/src/tangle/types.ts @@ -136,4 +136,4 @@ export interface IClientTangleDatabase { } export interface IWebTangleDatabase extends IClientTangleDatabase { -} +} +\ No newline at end of file diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts @@ -7,8 +7,6 @@ import type { IFarmFindManyResolve, IFarmFindOne, IFarmFindOneResolve, - IFarmLocationRelation, - IFarmLocationResolve, IFarmUpdate, IFarmUpdateResolve, ILocationGcsCreate, @@ -49,8 +47,6 @@ import type { INostrProfileFindManyResolve, INostrProfileFindOne, INostrProfileFindOneResolve, - INostrProfileRelayRelation, - INostrProfileRelayResolve, INostrProfileUpdate, INostrProfileUpdateResolve, INostrRelayCreate, @@ -71,24 +67,24 @@ import type { ITradeProductFindManyResolve, ITradeProductFindOne, ITradeProductFindOneResolve, + ITradeProductUpdate, + ITradeProductUpdateResolve, + IFarmLocationRelation, + IFarmLocationResolve, + INostrProfileRelayRelation, + INostrProfileRelayResolve, ITradeProductLocationRelation, ITradeProductLocationResolve, ITradeProductMediaRelation, - ITradeProductMediaResolve, - ITradeProductUpdate, - ITradeProductUpdateResolve + ITradeProductMediaResolve } from "@radroots/tangle-schema-bindings"; import init_wasm, { query_sql, - tangle_db_export_backup, tangle_db_farm_create, tangle_db_farm_delete, tangle_db_farm_find_many, tangle_db_farm_find_one, - tangle_db_farm_location_set, - tangle_db_farm_location_unset, tangle_db_farm_update, - tangle_db_import_backup, tangle_db_location_gcs_create, tangle_db_location_gcs_delete, tangle_db_location_gcs_find_many, @@ -108,25 +104,29 @@ import init_wasm, { tangle_db_nostr_profile_delete, tangle_db_nostr_profile_find_many, tangle_db_nostr_profile_find_one, - tangle_db_nostr_profile_relay_set, - tangle_db_nostr_profile_relay_unset, tangle_db_nostr_profile_update, tangle_db_nostr_relay_create, tangle_db_nostr_relay_delete, tangle_db_nostr_relay_find_many, tangle_db_nostr_relay_find_one, tangle_db_nostr_relay_update, - tangle_db_reset_database, - tangle_db_run_migrations, tangle_db_trade_product_create, tangle_db_trade_product_delete, tangle_db_trade_product_find_many, tangle_db_trade_product_find_one, + tangle_db_trade_product_update, + tangle_db_farm_location_set, + tangle_db_farm_location_unset, + tangle_db_nostr_profile_relay_set, + tangle_db_nostr_profile_relay_unset, tangle_db_trade_product_location_set, tangle_db_trade_product_location_unset, tangle_db_trade_product_media_set, tangle_db_trade_product_media_unset, - tangle_db_trade_product_update + tangle_db_reset_database, + tangle_db_run_migrations, + tangle_db_export_backup, + tangle_db_import_backup } from "@radroots/tangle-sql-wasm"; import type { IError } from "@radroots/types-bindings"; import { err_msg, handle_err, type IdbClientConfig } from "@radroots/utils"; @@ -538,4 +538,4 @@ export class WebTangleDatabase implements IWebTangleDatabase { return this.deserialize<ITradeProductMediaResolve>(res); } -} +} +\ No newline at end of file diff --git a/client/src/utils/resolve.ts b/client/src/utils/resolve.ts @@ -0,0 +1,5 @@ +import type { IError } from "@radroots/types-bindings"; +import type { ResolveError } from "@radroots/utils"; + +export const is_error = <T>(value: ResolveError<T>): value is IError<string> => + typeof value === "object" && value !== null && "err" in value;