web_lib

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

commit 45c56db5ce7b80378d4bd8b9768c609ba601ef23
parent 9ebce20c5565dd1c1c657a14226dff460541abd1
Author: triesap <triesap@radroots.dev>
Date:   Sun, 28 Dec 2025 17:02:15 +0000

tangle: add signed DB export and rename backup JSON types

- Rename backup export types to TangleDatabaseJsonExport across PWA and client
- Add WebTangleDatabase.export_database with manifest, sha256, and optional signer
- Implement ZIP export pipeline with save picker/share/download fallbacks
- Expose SQL WASM export bridge and add crypto_unavailable error key

Diffstat:
Mapps-lib-pwa/src/lib/types/app.ts | 4++--
Mclient/src/tangle/bridge.ts | 3++-
Mclient/src/tangle/error.ts | 3++-
Mclient/src/tangle/types.ts | 10+++++++---
Mclient/src/tangle/web.ts | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 482 insertions(+), 31 deletions(-)

diff --git a/apps-lib-pwa/src/lib/types/app.ts b/apps-lib-pwa/src/lib/types/app.ts @@ -1,4 +1,4 @@ -import type { TangleDatabaseBackup } from "@radroots/client/tangle"; +import type { TangleDatabaseJsonExport } from "@radroots/client/tangle"; import type { IdbClientConfig } from "@radroots/utils"; export type AppConfigRole = `farmer` | `individual` @@ -53,7 +53,7 @@ export type ExportedAppState = { }; database: { store_key: string; - backup: TangleDatabaseBackup; + backup: TangleDatabaseJsonExport; }; }; diff --git a/client/src/tangle/bridge.ts b/client/src/tangle/bridge.ts @@ -41,6 +41,7 @@ export function radroots_sql_install_bridges(engine: WebSqlEngine): void { const params = parse_sql_params(params_json); const res = engine.query(sql, params); return res; - } + }, + __radroots_sql_wasm_export_bytes: () => engine.export_bytes() }); } diff --git a/client/src/tangle/error.ts b/client/src/tangle/error.ts @@ -2,7 +2,8 @@ export const cl_tangle_error = { init_failure: "error.client.tangle.init_failure", parse_failure: "error.client.tangle.parse_failure", invalid_response: "error.client.tangle.invalid_response", - runtime_unavailable: "error.client.tangle.runtime_unavailable" + runtime_unavailable: "error.client.tangle.runtime_unavailable", + crypto_unavailable: "error.client.tangle.crypto_unavailable" } as const; export type ClientTangleError = keyof typeof cl_tangle_error; diff --git a/client/src/tangle/types.ts b/client/src/tangle/types.ts @@ -158,7 +158,10 @@ import type { } from "@radroots/tangle-db-schema-bindings"; import { type SqlJsMigrationState } from "../sql/types.js"; import type { IError } from "@radroots/types-bindings"; -import type { TangleDatabaseBackup } from "./web.js"; +import type { + TangleDatabaseExportOptions, + TangleDatabaseJsonExport +} from "./web.js"; export interface IClientTangleDatabase { init(): Promise<void>; @@ -167,8 +170,9 @@ export interface IClientTangleDatabase { reset(): Promise<SqlJsMigrationState | IError<string>>; reinit(): Promise<SqlJsMigrationState | IError<string>>; get_store_key(): string; - export_backup(): Promise<TangleDatabaseBackup | IError<string>>; - import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>>; + export_json(): Promise<TangleDatabaseJsonExport | IError<string>>; + import_json(backup: TangleDatabaseJsonExport): Promise<void | IError<string>>; + export_database(opts: TangleDatabaseExportOptions): Promise<void | IError<string>>; farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>>; farm_find_one(opts: IFarmFindOne): Promise<IFarmFindOneResolve | IError<string>>; farm_find_many(opts?: IFarmFindMany): Promise<IFarmFindManyResolve | IError<string>>; diff --git a/client/src/tangle/web.ts b/client/src/tangle/web.ts @@ -241,8 +241,10 @@ import init_wasm, { tangle_db_trade_product_media_unset, tangle_db_reset_database, tangle_db_run_migrations, - tangle_db_export_backup, - tangle_db_import_backup + tangle_db_export_begin, + tangle_db_export_finish, + tangle_db_export_json, + tangle_db_import_json } from "@radroots/tangle-db-wasm"; import type { IError } from "@radroots/types-bindings"; import { err_msg, handle_err, type IdbClientConfig } from "@radroots/utils"; @@ -253,26 +255,87 @@ import { radroots_sql_install_bridges } from "./bridge.js"; import { cl_tangle_error } from "./error.js"; import type { IWebTangleDatabase } from "./types.js"; -export type TangleDatabaseBackup = { +export type TangleDatabaseSchemaEntry = { + object_type: string; + name: string; + table_name?: string; + sql?: string; +}; + +export type TangleDatabaseMigrationEntry = { + name: string; + up_sql: string; + down_sql: string; +}; + +export type TangleDatabaseJsonExport = { format_version: string; tangle_db_version: string; - schema: { - object_type: string; - name: string; - table_name?: string; - sql?: string; - }[]; + schema: TangleDatabaseSchemaEntry[]; data: { name: string; rows: Record<string, unknown>[]; }[]; - migrations: { + migrations: TangleDatabaseMigrationEntry[]; +}; + +export type TangleDatabaseExportManifestRs = { + export_version: string; + tangle_db_version: string; + backup_format_version: string; + schema_hash: string; + schema: TangleDatabaseSchemaEntry[]; + migrations: TangleDatabaseMigrationEntry[]; + table_counts: { name: string; - up_sql: string; - down_sql: string; + row_count: number; }[]; }; +export type NostrEventEnvelope = { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +}; + +export type TangleDatabaseExportManifestTs = { + app_name: string; + app_version: string; + exported_at: string; + db_sha256: string; + db_size_bytes: number; + store_key: string; + nostr_event?: NostrEventEnvelope; +}; + +export type TangleDatabaseExportManifest = { + rs: TangleDatabaseExportManifestRs; + ts: TangleDatabaseExportManifestTs; +}; + +export type TangleDatabaseExportSnapshot = { + manifest_rs: TangleDatabaseExportManifestRs; + db_bytes: Uint8Array; +}; + +export type TangleDatabaseExportSignRequest = { + db_sha256: string; + manifest: TangleDatabaseExportManifest; +}; + +export type TangleDatabaseExportSigner = (opts: TangleDatabaseExportSignRequest) => Promise<NostrEventEnvelope | null>; + +export type TangleDatabaseExportOptions = { + app_name: string; + app_version: string; + store_key?: string; + signer?: TangleDatabaseExportSigner; +}; + export type WebTangleDatabaseConfig = { store_key?: string; idb_config?: IdbClientConfig; @@ -294,7 +357,7 @@ const is_sql_migration_row = (value: unknown): value is SqlJsMigrationRow => { const is_sql_migration_row_list = (value: unknown): value is SqlJsMigrationRow[] => Array.isArray(value) && value.every(is_sql_migration_row); -const is_backup_schema_entry = (value: unknown): value is TangleDatabaseBackup["schema"][number] => { +const is_schema_entry = (value: unknown): value is TangleDatabaseSchemaEntry => { if (!is_record(value)) return false; if (typeof value.object_type !== "string") return false; if (typeof value.name !== "string") return false; @@ -303,7 +366,7 @@ const is_backup_schema_entry = (value: unknown): value is TangleDatabaseBackup[" return true; }; -const is_backup_data_entry = (value: unknown): value is TangleDatabaseBackup["data"][number] => { +const is_json_export_data_entry = (value: unknown): value is TangleDatabaseJsonExport["data"][number] => { if (!is_record(value)) return false; if (typeof value.name !== "string") return false; if (!Array.isArray(value.rows)) return false; @@ -311,23 +374,330 @@ const is_backup_data_entry = (value: unknown): value is TangleDatabaseBackup["da return true; }; -const is_backup_migration_entry = (value: unknown): value is TangleDatabaseBackup["migrations"][number] => { +const is_migration_entry = (value: unknown): value is TangleDatabaseMigrationEntry => { if (!is_record(value)) return false; return typeof value.name === "string" && typeof value.up_sql === "string" && typeof value.down_sql === "string"; }; -const is_tangle_database_backup = (value: unknown): value is TangleDatabaseBackup => { +const is_table_count_entry = (value: unknown): value is TangleDatabaseExportManifestRs["table_counts"][number] => { + if (!is_record(value)) return false; + if (typeof value.name !== "string") return false; + if (typeof value.row_count !== "number" || !Number.isFinite(value.row_count)) return false; + return true; +}; + +const is_tangle_database_json_export = (value: unknown): value is TangleDatabaseJsonExport => { if (!is_record(value)) return false; if (typeof value.format_version !== "string") return false; if (typeof value.tangle_db_version !== "string") return false; - if (!Array.isArray(value.schema) || !value.schema.every(is_backup_schema_entry)) return false; - if (!Array.isArray(value.data) || !value.data.every(is_backup_data_entry)) return false; - if (!Array.isArray(value.migrations) || !value.migrations.every(is_backup_migration_entry)) return false; + if (!Array.isArray(value.schema) || !value.schema.every(is_schema_entry)) return false; + if (!Array.isArray(value.data) || !value.data.every(is_json_export_data_entry)) return false; + if (!Array.isArray(value.migrations) || !value.migrations.every(is_migration_entry)) return false; + return true; +}; + +const is_export_manifest_rs = (value: unknown): value is TangleDatabaseExportManifestRs => { + if (!is_record(value)) return false; + if (typeof value.export_version !== "string") return false; + if (typeof value.tangle_db_version !== "string") return false; + if (typeof value.backup_format_version !== "string") return false; + if (typeof value.schema_hash !== "string") return false; + if (!Array.isArray(value.schema) || !value.schema.every(is_schema_entry)) return false; + if (!Array.isArray(value.migrations) || !value.migrations.every(is_migration_entry)) return false; + if (!Array.isArray(value.table_counts) || !value.table_counts.every(is_table_count_entry)) return false; + return true; +}; + +const is_export_snapshot = (value: unknown): value is TangleDatabaseExportSnapshot => { + if (!is_record(value)) return false; + if (!("manifest_rs" in value) || !is_export_manifest_rs(value.manifest_rs)) return false; + if (!("db_bytes" in value) || !(value.db_bytes instanceof Uint8Array)) return false; return true; }; +type ZipEntry = { + name: string; + data: Uint8Array; +}; + +type ZipEntryPrepared = { + name_bytes: Uint8Array; + data: Uint8Array; + crc32: number; + size: number; +}; + +type ZipFilePickerOptions = { + suggestedName?: string; + types?: { + description?: string; + accept: Record<string, string[]>; + }[]; +}; + +type ZipFileHandle = { + createWritable(): Promise<ZipFileWritable>; +}; + +type ZipFileWritable = { + write(data: Uint8Array): Promise<void>; + close(): Promise<void>; +}; + +type ZipFilePicker = (options?: ZipFilePickerOptions) => Promise<ZipFileHandle>; + +const ZIP_CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + if (c & 1) c = 0xedb88320 ^ (c >>> 1); + else c >>>= 1; + } + table[i] = c >>> 0; + } + return table; +})(); + +const crc32 = (data: Uint8Array): number => { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + const idx = (crc ^ data[i]) & 0xff; + crc = ZIP_CRC_TABLE[idx] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const zip_prepare_entries = (entries: ZipEntry[]): ZipEntryPrepared[] => { + const enc = new TextEncoder(); + return entries.map((entry) => ({ + name_bytes: enc.encode(entry.name), + data: entry.data, + crc32: crc32(entry.data), + size: entry.data.length + })); +}; + +const zip_dos_time = (date: Date): { time: number; date: number } => { + const year = Math.max(1980, date.getFullYear()); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = Math.floor(date.getSeconds() / 2); + const time = (hours << 11) | (minutes << 5) | seconds; + const date_val = ((year - 1980) << 9) | (month << 5) | day; + return { time, date: date_val }; +}; + +const zip_local_header = (entry: ZipEntryPrepared, time: number, date: number): Uint8Array => { + const name_len = entry.name_bytes.length; + const buffer = new ArrayBuffer(30 + name_len); + const view = new DataView(buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0, true); + view.setUint16(8, 0, true); + view.setUint16(10, time, true); + view.setUint16(12, date, true); + view.setUint32(14, entry.crc32, true); + view.setUint32(18, entry.size, true); + view.setUint32(22, entry.size, true); + view.setUint16(26, name_len, true); + view.setUint16(28, 0, true); + const out = new Uint8Array(buffer); + out.set(entry.name_bytes, 30); + return out; +}; + +const zip_central_header = ( + entry: ZipEntryPrepared, + time: number, + date: number, + offset: number +): Uint8Array => { + const name_len = entry.name_bytes.length; + const buffer = new ArrayBuffer(46 + name_len); + const view = new DataView(buffer); + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, 0, true); + view.setUint16(10, 0, true); + view.setUint16(12, time, true); + view.setUint16(14, date, true); + view.setUint32(16, entry.crc32, true); + view.setUint32(20, entry.size, true); + view.setUint32(24, entry.size, true); + view.setUint16(28, name_len, true); + view.setUint16(30, 0, true); + view.setUint16(32, 0, true); + view.setUint16(34, 0, true); + view.setUint16(36, 0, true); + view.setUint32(38, 0, true); + view.setUint32(42, offset, true); + const out = new Uint8Array(buffer); + out.set(entry.name_bytes, 46); + return out; +}; + +const zip_end_record = (entry_count: number, central_size: number, central_offset: number): Uint8Array => { + const buffer = new ArrayBuffer(22); + const view = new DataView(buffer); + view.setUint32(0, 0x06054b50, true); + view.setUint16(4, 0, true); + view.setUint16(6, 0, true); + view.setUint16(8, entry_count, true); + view.setUint16(10, entry_count, true); + view.setUint32(12, central_size, true); + view.setUint32(16, central_offset, true); + view.setUint16(20, 0, true); + return new Uint8Array(buffer); +}; + +const zip_build_bytes = (entries: ZipEntry[]): Uint8Array => { + const prepared = zip_prepare_entries(entries); + const { time, date } = zip_dos_time(new Date()); + const local_parts: Uint8Array[] = []; + const central_parts: Uint8Array[] = []; + let offset = 0; + + for (const entry of prepared) { + const local = zip_local_header(entry, time, date); + local_parts.push(local, entry.data); + const central = zip_central_header(entry, time, date, offset); + central_parts.push(central); + offset += local.length + entry.data.length; + } + + let central_size = 0; + for (const part of central_parts) central_size += part.length; + const end = zip_end_record(prepared.length, central_size, offset); + const total = offset + central_size + end.length; + const out = new Uint8Array(total); + let cursor = 0; + for (const part of local_parts) { + out.set(part, cursor); + cursor += part.length; + } + for (const part of central_parts) { + out.set(part, cursor); + cursor += part.length; + } + out.set(end, cursor); + return out; +}; + +const zip_write_stream = async (stream: ZipFileWritable, entries: ZipEntry[]): Promise<void> => { + const prepared = zip_prepare_entries(entries); + const { time, date } = zip_dos_time(new Date()); + const central_parts: Uint8Array[] = []; + let offset = 0; + + for (const entry of prepared) { + const local = zip_local_header(entry, time, date); + await stream.write(local); + await stream.write(entry.data); + central_parts.push(zip_central_header(entry, time, date, offset)); + offset += local.length + entry.data.length; + } + + let central_size = 0; + for (const part of central_parts) central_size += part.length; + for (const part of central_parts) await stream.write(part); + const end = zip_end_record(prepared.length, central_size, offset); + await stream.write(end); + await stream.close(); +}; + +const bytes_to_hex = (bytes: Uint8Array): string => { + const hex: string[] = []; + for (let i = 0; i < bytes.length; i++) { + hex.push(bytes[i].toString(16).padStart(2, "0")); + } + return hex.join(""); +}; + +const sha256_hex = async (bytes: Uint8Array): Promise<string> => { + if (!globalThis.crypto || !globalThis.crypto.subtle) throw new Error(cl_tangle_error.crypto_unavailable); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return bytes_to_hex(new Uint8Array(digest)); +}; + +const filename_slug = (value: string): string => { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (slug.startsWith("radroots-")) return slug; + return `radroots-${slug}`; +}; + +const export_filename = (app_name: string, app_version: string, exported_at: string): string => { + const ts = exported_at.replace(/[:.]/g, "-"); + const base = filename_slug(app_name); + return `${base}-${app_version}-backup-${ts}.zip`; +}; + +const get_zip_file_picker = (): ZipFilePicker | undefined => { + if (typeof window === "undefined") return undefined; + const picker = (window as Window & { showSaveFilePicker?: ZipFilePicker }).showSaveFilePicker; + return picker; +}; + +const can_share_file = (file: File): boolean => { + if (typeof navigator === "undefined") return false; + const nav = navigator as Navigator & { canShare?: (data: { files?: File[] }) => boolean }; + if (!nav.canShare) return false; + return nav.canShare({ files: [file] }); +}; + +const share_file = async (file: File): Promise<boolean> => { + if (typeof navigator === "undefined") return false; + const nav = navigator as Navigator & { share?: (data: { files?: File[]; title?: string }) => Promise<void> }; + if (!nav.share) return false; + await nav.share({ files: [file], title: file.name }); + return true; +}; + +const download_blob = (blob: Blob, filename: string): void => { + if (typeof document === "undefined") return; + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}; + +const export_zip = async (filename: string, entries: ZipEntry[]): Promise<void> => { + const picker = get_zip_file_picker(); + if (picker) { + const handle = await picker({ + suggestedName: filename, + types: [ + { + description: "Radroots tangle export", + accept: { "application/zip": [".zip"] } + } + ] + }); + const stream = await handle.createWritable(); + await zip_write_stream(stream, entries); + return; + } + const zip_bytes = zip_build_bytes(entries); + const blob = new Blob([zip_bytes], { type: "application/zip" }); + const file = new File([blob], filename, { type: "application/zip" }); + if (can_share_file(file)) { + const shared = await share_file(file); + if (shared) return; + } + download_blob(blob, filename); +}; + const DEFAULT_TANGLE_STORE_KEY = "radroots-pwa-v1-tangle-db"; const DEFAULT_TANGLE_IDB_CONFIG: IdbClientConfig = IDB_CONFIG_TANGLE; let wasm_init_promise: Promise<void> | null = null; @@ -468,10 +838,10 @@ export class WebTangleDatabase implements IWebTangleDatabase { } } - async export_backup(): Promise<TangleDatabaseBackup | IError<string>> { + async export_json(): Promise<TangleDatabaseJsonExport | IError<string>> { try { await this.ensure_ready(); - const res = await tangle_db_export_backup(); + const res = await tangle_db_export_json(); let parsed: unknown = res; if (typeof res === "string") { try { @@ -480,23 +850,98 @@ export class WebTangleDatabase implements IWebTangleDatabase { return err_msg(cl_tangle_error.parse_failure); } } - if (!is_tangle_database_backup(parsed)) return err_msg(cl_tangle_error.invalid_response); + if (!is_tangle_database_json_export(parsed)) return err_msg(cl_tangle_error.invalid_response); return parsed; } catch (e) { return handle_err(e); } } - async import_backup(backup: TangleDatabaseBackup): Promise<void | IError<string>> { + async import_json(backup: TangleDatabaseJsonExport): Promise<void | IError<string>> { try { await this.ensure_ready(); - tangle_db_import_backup(this.serialize(backup)); + tangle_db_import_json(this.serialize(backup)); + return; + } catch (e) { + return handle_err(e); + } + } + + async export_database(opts: TangleDatabaseExportOptions): Promise<void | IError<string>> { + try { + if (opts.store_key && opts.store_key !== this.store_key) { + const alt_db = new WebTangleDatabase({ + store_key: opts.store_key, + idb_config: this.idb_config, + cipher_config: this.cipher_config, + sql_wasm_path: this.sql_wasm_path + }); + const res = await alt_db.export_database(opts); + await alt_db.close(); + return res; + } + await this.export_database_inner(opts); return; } catch (e) { return handle_err(e); } } + private async export_database_inner(opts: TangleDatabaseExportOptions): Promise<void> { + await this.ensure_ready(); + const app_name = opts.app_name; + const app_version = opts.app_version; + const store_key = this.store_key; + let export_active = false; + + try { + const snapshot_raw = await tangle_db_export_begin(); + export_active = true; + if (!is_export_snapshot(snapshot_raw)) throw new Error(cl_tangle_error.invalid_response); + const manifest_rs = snapshot_raw.manifest_rs; + const db_bytes = snapshot_raw.db_bytes; + const db_sha256 = await sha256_hex(db_bytes); + const exported_at = new Date().toISOString(); + + const manifest_ts_base: TangleDatabaseExportManifestTs = { + app_name, + app_version, + exported_at, + db_sha256, + db_size_bytes: db_bytes.byteLength, + store_key + }; + + let manifest: TangleDatabaseExportManifest = { + rs: manifest_rs, + ts: manifest_ts_base + }; + + if (opts.signer) { + const nostr_event = await opts.signer({ db_sha256, manifest }); + if (nostr_event) { + manifest = { + rs: manifest_rs, + ts: { + ...manifest_ts_base, + nostr_event + } + }; + } + } + + const manifest_json = JSON.stringify(manifest, null, 2); + const manifest_bytes = new TextEncoder().encode(manifest_json); + const filename = export_filename(app_name, app_version, exported_at); + await export_zip(filename, [ + { name: "manifest.json", data: manifest_bytes }, + { name: "tangle.db", data: db_bytes } + ]); + } finally { + if (export_active) tangle_db_export_finish(); + } + } + async farm_create(opts: IFarmCreate): Promise<IFarmCreateResolve | IError<string>> { await this.ensure_ready(); const res = await tangle_db_farm_create(this.serialize(opts));