From e1204fd95a59d98312c88de72c6f2575714d1d11 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 18 Mar 2025 19:09:21 +0200 Subject: [PATCH] identifier builder --- package-lock.json | 14 +++- package.json | 1 + src/context/GlobalProvider.tsx | 11 +++- src/hooks/useAppInfo.tsx | 6 +- src/hooks/useIdentifiers.tsx | 41 ++++++++++++ src/state/app.ts | 10 ++- src/utils/encryption.ts | 114 +++++++++++++++++++++++++++++++-- 7 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useIdentifiers.tsx diff --git a/package-lock.json b/package-lock.json index dbd31ae..95a9cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qapp-core", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qapp-core", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -18,6 +18,7 @@ "buffer": "^6.0.3", "react": "^19.0.0", "react-intersection-observer": "^9.16.0", + "short-unique-id": "^5.2.0", "zustand": "^4.3.2" }, "devDependencies": { @@ -2285,6 +2286,15 @@ "node": ">=8" } }, + "node_modules/short-unique-id": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.2.0.tgz", + "integrity": "sha512-cMGfwNyfDZ/nzJ2k2M+ClthBIh//GlZl1JEf47Uoa9XR11bz8Pa2T2wQO4bVrRdH48LrIDWJahQziKo3MjhsWg==", + "bin": { + "short-unique-id": "bin/short-unique-id", + "suid": "bin/short-unique-id" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", diff --git a/package.json b/package.json index adf0c68..f801656 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "buffer": "^6.0.3", "react": "^19.0.0", "react-intersection-observer": "^9.16.0", + "short-unique-id": "^5.2.0", "zustand": "^4.3.2" }, "devDependencies": { diff --git a/src/context/GlobalProvider.tsx b/src/context/GlobalProvider.tsx index 0ad0d9e..0a35129 100644 --- a/src/context/GlobalProvider.tsx +++ b/src/context/GlobalProvider.tsx @@ -2,12 +2,16 @@ import React, { createContext, useContext, useMemo } from "react"; import { useAuth, UseAuthProps } from "../hooks/useAuth"; import { useResources } from "../hooks/useResources"; import { useAppInfo } from "../hooks/useAppInfo"; +import { IdentifierBuilder } from "../utils/encryption"; +import { useIdentifiers } from "../hooks/useIdentifiers"; // ✅ Define Global Context Type interface GlobalContextType { auth: ReturnType; resources: ReturnType; +appInfo: ReturnType; +identifierOperations: ReturnType } // ✅ Define Config Type for Hook Options @@ -19,21 +23,22 @@ interface GlobalProviderProps { appName: string; publicSalt: string }; + identifierBuilder?: IdentifierBuilder } // ✅ Create Context with Proper Type const GlobalContext = createContext(null); // 🔹 Global Provider (Handles Multiple Hooks) -export const GlobalProvider = ({ children, config }: GlobalProviderProps) => { +export const GlobalProvider = ({ children, config, identifierBuilder }: GlobalProviderProps) => { // ✅ Call hooks and pass in options dynamically const auth = useAuth(config?.auth || {}); const appInfo = useAppInfo(config?.appName, config?.publicSalt) const resources = useResources() + const identifierOperations = useIdentifiers(identifierBuilder, config?.publicSalt) // ✅ Merge all hooks into a single `contextValue` - const contextValue = useMemo(() => ({ auth, resources, appInfo }), [auth, resources, appInfo]); - + const contextValue = useMemo(() => ({ auth, resources, appInfo, identifierOperations }), [auth, resources, appInfo, identifierOperations]); return ( {children} diff --git a/src/hooks/useAppInfo.tsx b/src/hooks/useAppInfo.tsx index 9366c4e..1f3707c 100644 --- a/src/hooks/useAppInfo.tsx +++ b/src/hooks/useAppInfo.tsx @@ -6,10 +6,8 @@ import { EnumCollisionStrength, hashWord } from "../utils/encryption"; export const useAppInfo = (appName?: string, publicSalt?: string) => { const setAppState = useAppStore().setAppState - const appNameHashed = useMemo(()=> { - if(!appName) return "" - - }, [appName]) + const appNameHashed = useAppStore().appNameHashed + const handleAppInfoSetup = useCallback(async (name: string, salt: string)=> { const appNameHashed = await hashWord(name, EnumCollisionStrength.LOW, salt) diff --git a/src/hooks/useIdentifiers.tsx b/src/hooks/useIdentifiers.tsx new file mode 100644 index 0000000..77129a8 --- /dev/null +++ b/src/hooks/useIdentifiers.tsx @@ -0,0 +1,41 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useAuthStore } from "../state/auth"; +import { useAppStore } from "../state/app"; +import { buildIdentifier, buildSearchPrefix, IdentifierBuilder } from "../utils/encryption"; + + +export const useIdentifiers = (builder?: IdentifierBuilder, publicSalt?: string) => { + const setIdentifierBuilder = useAppStore().setIdentifierBuilder + const identifierBuilder = useAppStore().identifierBuilder + const appName = useAppStore().appName + + const stringifiedBuilder = useMemo(()=> { + return JSON.stringify(builder) + }, [builder]) + + const buildIdentifierFunc = useCallback(( entityType: string, + parentId: string | null)=> { + if(!appName || !publicSalt || !identifierBuilder) return null + return buildIdentifier(appName, publicSalt, entityType, parentId, identifierBuilder) + }, [appName, publicSalt, identifierBuilder]) + + const buildSearchPrefixFunc = useCallback(( entityType: string, + parentId: string | null)=> { + if(!appName || !publicSalt || !identifierBuilder) return null + return buildSearchPrefix(appName, publicSalt, entityType, parentId, identifierBuilder) + }, [appName, publicSalt, identifierBuilder]) + + + + + useEffect(()=> { + if(stringifiedBuilder){ + setIdentifierBuilder(JSON.parse(stringifiedBuilder)) + } + }, [stringifiedBuilder]) + return { + identifierBuilder, + buildIdentifier: buildIdentifierFunc, + buildSearchPrefix: buildSearchPrefixFunc + }; +}; diff --git a/src/state/app.ts b/src/state/app.ts index 96ae389..ee9b263 100644 --- a/src/state/app.ts +++ b/src/state/app.ts @@ -1,12 +1,14 @@ import { create } from "zustand"; +import { IdentifierBuilder } from "../utils/encryption"; interface AppState { appName: string | null; publicSalt: string | null; appNameHashed: string | null; - + identifierBuilder?: IdentifierBuilder | null // Methods setAppState: (appState: { appName: string; publicSalt: string; appNameHashed: string }) => void; + setIdentifierBuilder: (builder: IdentifierBuilder) => void; } // ✅ Typed Zustand Store @@ -14,8 +16,10 @@ export const useAppStore = create((set) => ({ appName: null, publicSalt: null, appNameHashed: null, - + identifierBuilder: null, // Methods setAppState: (appState) => - set({ appName: appState.appName, publicSalt: appState.publicSalt, appNameHashed: appState.appNameHashed }) + set({ appName: appState.appName, publicSalt: appState.publicSalt, appNameHashed: appState.appNameHashed }), + setIdentifierBuilder: (identifierBuilder) => + set({ identifierBuilder }) })); diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index ba98a04..7834cca 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -1,15 +1,117 @@ import { Buffer } from "buffer"; +import ShortUniqueId from "short-unique-id"; export enum EnumCollisionStrength { LOW = 8, MEDIUM = 11, - HIGH = 14 + HIGH = 14, + PARENT_REF = 14, + ENTITY_LABEL= 6 } -export async function hashWord(word: string, collisionStrength: number, publicSalt: string) { - const saltedWord = publicSalt + word; // Use public salt directly - const encoded = new TextEncoder().encode(saltedWord); - const hashBuffer = await crypto.subtle.digest("SHA-256", encoded); - return Buffer.from(hashBuffer).toString("base64").slice(0, collisionStrength); +export async function hashWord(word: string, collisionStrength: number, publicSalt: string) { + const saltedWord = publicSalt + word; // Use public salt directly + const encoded = new TextEncoder().encode(saltedWord); + const hashBuffer = await crypto.subtle.digest("SHA-256", encoded); + + // Convert to base64 and make it URL-safe + return Buffer.from(hashBuffer) + .toString("base64") + .replace(/\+/g, "-") // Replace '+' with '-' + .replace(/\//g, "_") // Replace '/' with '_' + .replace(/=+$/, "") // Remove trailing '=' + .slice(0, collisionStrength); +} + + + +const uid = new ShortUniqueId({ length: 10, dictionary: "alphanum" }); + +interface EntityConfig { + children?: Record; +} + +export type IdentifierBuilder = { + [key: string]: { + children?: IdentifierBuilder; + }; +}; + +// Recursive function to traverse identifierBuilder +function findEntityConfig(identifierBuilder: IdentifierBuilder, path: string[]): EntityConfig { + let current: EntityConfig | undefined = { children: identifierBuilder }; // ✅ Wrap it inside `{ children }` so it behaves like other levels + + + for (const key of path) { + if (!current.children || !current.children[key]) { + throw new Error(`Entity '${key}' is not defined in identifierBuilder`); + } + current = current.children[key]; + } + + return current; +} + + +// Function to generate a prefix for searching +export async function buildSearchPrefix( + appName: string, + publicSalt: string, + entityType: string, + parentId: string | null, + identifierBuilder: IdentifierBuilder +): Promise { + // Hash app name (11 chars) + const appHash: string = await hashWord(appName, EnumCollisionStrength.HIGH, publicSalt); + + // Hash entity type (4 chars) + const entityPrefix: string = await hashWord(entityType, EnumCollisionStrength.ENTITY_LABEL, publicSalt); + + // ✅ Detect if this entity is actually a root entity + const isRootEntity = !!identifierBuilder[entityType]; + + // Determine parent reference + let parentRef = ""; + if (isRootEntity && parentId === null) { + parentRef = "00000000000000"; // ✅ Only for true root entities + } else if (parentId) { + parentRef = await hashWord(parentId, EnumCollisionStrength.PARENT_REF, publicSalt); + } + + // ✅ If there's no parentRef, return without it + return parentRef + ? `${appHash}-${entityPrefix}-${parentRef}-` // ✅ Normal case with a parent + : `${appHash}-${entityPrefix}-`; // ✅ Global search for entity type +} + + + +// Function to generate IDs dynamically with `publicSalt` +export async function buildIdentifier( + appName: string, + publicSalt: string, + entityType: string, // ✅ Now takes only the entity type + parentId: string | null, + identifierBuilder: IdentifierBuilder +): Promise { + console.log("Entity Type:", entityType); // Debugging + console.log("Parent ID:", parentId); // Debugging + + // Hash app name (11 chars) + const appHash: string = await hashWord(appName, EnumCollisionStrength.HIGH, publicSalt); + + // Hash entity type (4 chars) + const entityPrefix: string = await hashWord(entityType, EnumCollisionStrength.ENTITY_LABEL, publicSalt); + + // Generate a unique identifier for this entity + const entityUid = uid.rnd(); + + // Determine parent reference + let parentRef = "00000000000000"; // Default for feeds + if (parentId) { + parentRef = await hashWord(parentId, EnumCollisionStrength.PARENT_REF, publicSalt); + } + + return `${appHash}-${entityPrefix}-${parentRef}-${entityUid}`; }