Updated a lot of stuff

This commit is contained in:
Alois 2026-03-14 19:36:23 +01:00
commit c0102df54f
44 changed files with 1610 additions and 707 deletions

View file

@ -1,5 +1,7 @@
import * as React from "react";
import { useCrypto } from "@tensamin/crypto/context";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
import {
PING_INTERVAL,
@ -14,18 +16,90 @@ import {
import Loading from "@tensamin/ui/screens/loading";
import ErrorScreen from "@tensamin/ui/screens/error";
const FATAL_IDENTIFICATION_ERROR_TYPES = new Set([
"error_invalid_user_id",
"error_no_user_id",
"error_invalid_challenge",
"error_invalid_secret",
"error_invalid_private_key",
"error_invalid_public_key",
"error_not_authenticated",
]);
function isStopSendingError(error: unknown) {
if (typeof error === "string") {
return error.includes("STOP_SENDING");
}
if (error instanceof Error) {
if (error.message.includes("STOP_SENDING")) {
return true;
}
const errorWithCause = error as Error & { cause?: unknown };
if (errorWithCause.cause !== undefined) {
return isStopSendingError(errorWithCause.cause);
}
return false;
}
if (typeof error === "object" && error !== null) {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === "string") {
return maybeMessage.includes("STOP_SENDING");
}
}
return false;
}
function isFatalIdentificationError(error: unknown) {
if (typeof error === "object" && error !== null && "type" in error) {
const type = (error as { type?: unknown }).type;
if (
typeof type === "string" &&
FATAL_IDENTIFICATION_ERROR_TYPES.has(type)
) {
return true;
}
}
if (!(error instanceof Error)) {
return false;
}
if (
error.message.includes("Missing or invalid user id") ||
error.message.includes("Missing private key") ||
error.message.includes("Identification challenge was rejected")
) {
return true;
}
return false;
}
type ContextType = {
send: BoundSendFn<Schemas>;
readyState: () => number;
ownPing: () => number;
iotaPing: () => number;
identified: () => boolean;
};
const socketContext = React.createContext<ContextType | undefined>(undefined);
export default function Provider(props: { children: React.ReactNode }) {
const [readyState, setReadyState] = React.useState<number>(READY_STATE.CLOSED);
const { load } = useStorage();
const { decrypt, get_shared_secret } = useCrypto();
const [readyState, setReadyState] = React.useState<number>(
READY_STATE.CLOSED,
);
const [connected, setConnected] = React.useState<boolean>(false);
const [identified, setIdentified] = React.useState<boolean>(false);
const [identifying, setIdentifying] = React.useState<boolean>(false);
const [ownPing, setOwnPing] = React.useState<number>(0);
const [iotaPing, setIotaPing] = React.useState<number>(0);
@ -36,6 +110,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const clientRef = React.useRef<ReturnType<
typeof createTransportClient<Schemas>
> | null>(null);
const identificationStartedRef = React.useRef(false);
const send = React.useCallback<BoundSendFn<Schemas>>(
((
@ -65,7 +140,7 @@ export default function Provider(props: { children: React.ReactNode }) {
);
React.useEffect(() => {
if (!connected) {
if (!connected || !identified) {
return;
}
@ -92,7 +167,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return () => {
clearInterval(interval);
};
}, [connected, send]);
}, [connected, identified, send]);
React.useEffect(() => {
let attempts = 0;
@ -141,17 +216,34 @@ export default function Provider(props: { children: React.ReactNode }) {
if (state === READY_STATE.OPEN) {
attempts = 0;
clearReconnectTimer();
identificationStartedRef.current = false;
setConnected(true);
setIdentified(false);
setError("");
setErrorDescription("");
log(1, "Socket", "green", "Connected");
return;
}
identificationStartedRef.current = false;
setConnected(false);
setIdentified(false);
},
onClose: ({ error: closeError, intentional }) => {
if (isStopSendingError(closeError)) {
clearReconnectTimer();
setConnected(false);
setIdentified(false);
setIdentifying(false);
setError("Connection closed");
setErrorDescription(
"The connection was forcefully closed by the Omikron.",
);
log(0, "Socket", "red", "Connection closed", closeError);
return;
}
setConnected(false);
},
onClose: ({ error: closeError, intentional }) => {
setConnected(false);
setIdentified(false);
if (disposed || intentional) {
return;
@ -176,6 +268,13 @@ export default function Provider(props: { children: React.ReactNode }) {
return;
}
if (isStopSendingError(connectError)) {
clearReconnectTimer();
setError("Connection closed");
setErrorDescription("Connection closed");
return;
}
log(0, "Socket", "red", "Connection attempt failed", connectError);
scheduleReconnect(connectError);
}
@ -194,14 +293,147 @@ export default function Provider(props: { children: React.ReactNode }) {
void transportClient.close("context-dispose");
setReadyState(READY_STATE.CLOSED);
setConnected(false);
setIdentified(false);
setIdentifying(false);
identificationStartedRef.current = false;
};
}, []);
React.useEffect(() => {
if (!connected) {
setIdentifying(false);
setIdentified(false);
identificationStartedRef.current = false;
return;
}
if (identificationStartedRef.current) {
return;
}
identificationStartedRef.current = true;
let cancelled = false;
setIdentifying(true);
setIdentified(false);
const identify = async () => {
try {
const userId = await load("user_id");
const privateKey = await load("private_key");
if (!Number.isSafeInteger(userId) || userId <= 0) {
throw new Error("Missing or invalid user id for identification");
}
if (privateKey.trim() === "") {
throw new Error("Missing private key for identification");
}
const challengeEnvelope = await send("identification", {
user_id: userId,
});
const sharedSecret = await get_shared_secret(
privateKey,
"",
challengeEnvelope.data.public_key,
);
const decryptedChallenge = await decrypt(
sharedSecret,
challengeEnvelope.data.challenge,
);
const verification = await send("challenge_response", {
challenge: decryptedChallenge,
});
if (verification.data.accepted !== true) {
throw new Error("Identification challenge was rejected");
}
if (cancelled) {
return;
}
setError("");
setErrorDescription("");
setIdentified(true);
} catch (identificationError) {
if (cancelled) {
return;
}
if (isStopSendingError(identificationError)) {
setError("Connection closed");
setErrorDescription("Connection closed");
setIdentified(false);
return;
}
const isFatal = isFatalIdentificationError(identificationError);
log(
isFatal ? 0 : 1,
"Socket",
isFatal ? "red" : "yellow",
"Identification handshake failed",
identificationError,
);
setIdentified(false);
setError("Identification Failed");
setErrorDescription(
isFatal
? "Unable to complete secure identification. Please verify your credentials and try again."
: "Unable to complete secure identification because the transport request failed.",
);
} finally {
if (!cancelled) {
setIdentifying(false);
}
}
};
void identify();
return () => {
cancelled = true;
};
}, [connected, decrypt, get_shared_secret, load, send]);
const progress = React.useMemo(() => {
if (readyState === READY_STATE.CONNECTING) return 70;
if (!connected) return 90;
if (readyState === READY_STATE.CONNECTING) return 30;
if (!connected) return 45;
if (identifying) return 75;
if (!identified) return 90;
return 100;
}, [connected, readyState]);
}, [connected, identified, identifying, readyState]);
const loadingTitle = React.useMemo(() => {
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Connecting to Tensamin";
}
if (identifying || !identified) {
return "Identifying secure session";
}
return "Loading";
}, [connected, identified, identifying, readyState]);
const loadingDescription = React.useMemo(() => {
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) {
return "Verifying challenge-response handshake";
}
return undefined;
}, [connected, identified, identifying, readyState]);
const contextValue = React.useMemo<ContextType>(
() => ({
@ -209,16 +441,24 @@ export default function Provider(props: { children: React.ReactNode }) {
readyState: () => readyState,
ownPing: () => ownPing,
iotaPing: () => iotaPing,
identified: () => identified,
}),
[iotaPing, ownPing, readyState, send],
[identified, iotaPing, ownPing, readyState, send],
);
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}
if (!connected) {
return <Loading progress={progress} />;
if (!connected || !identified) {
return (
<Loading
progress={progress}
title={loadingTitle}
description={loadingDescription}
fullscreen
/>
);
}
return (
@ -234,4 +474,4 @@ export function useSocket(): ContextType {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
}
}

View file

@ -269,6 +269,7 @@ registerDataKinds("number", [
"omikron_id",
"send_time",
"sub_level",
"sub_end",
]);
registerDataKinds("string", [
@ -301,6 +302,7 @@ registerDataKinds("string", [
"new_token",
"call_token",
"challenge",
"online_status",
]);
registerDataKinds({ array: "container" }, [
@ -351,10 +353,8 @@ registerDataKinds("null", [
"watcher",
"created_at",
"status",
"sub_end",
"community_address",
"community_title",
"online_status",
"call_invited",
"call_members",
"calls",
@ -476,9 +476,31 @@ export function createTransportClient<T extends SchemaMap>(
notifyClosed(connection, error);
};
const closeFromStopSending = (
connection: ActiveConnection,
error: unknown,
) => {
if (!isStopSendingError(error)) {
return;
}
try {
connection.transport.close({
closeCode: APPLICATION_CLOSE_CODE,
reason: "stop-sending",
});
} catch {
// Ignore close failures while handling STOP_SENDING.
}
handleConnectionFailure(connection, error);
};
const handleIncomingMessage = (message: TypedMessage) => {
if (message.type !== "pong") {
log(2, "Socket", "blue", message.type, message.data);
log(2, "Socket", "blue", "Received:", message.type, message.data, {
id: message.id,
});
}
if (message.id !== 0) {
@ -728,13 +750,20 @@ export function createTransportClient<T extends SchemaMap>(
payload = coercePayload(input ?? {});
}
if (!options?.id && !options?.noResponse) {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
options = options ?? {};
options.id = array[0];
}
if (type !== "ping") {
log(2, "Socket", "purple", type, payload);
log(2, "Socket", "purple", "Sent:", type, payload, { id: options.id });
}
const expectsResponse = !options?.noResponse;
const requestId = resolveRequestId(
options?.id,
options.id,
expectsResponse,
pending,
() => {
@ -751,7 +780,10 @@ export function createTransportClient<T extends SchemaMap>(
});
if (!expectsResponse) {
return writeMessage(connection.transport, messageBytes);
return writeMessage(connection.transport, messageBytes).catch((error) => {
closeFromStopSending(connection, error);
throw error;
});
}
return new Promise<TypedMessage>((resolve, reject) => {
@ -772,6 +804,7 @@ export function createTransportClient<T extends SchemaMap>(
});
void writeMessage(connection.transport, messageBytes).catch((error) => {
closeFromStopSending(connection, error);
clearTimeout(timeoutId);
pending.delete(requestId);
reject(error);
@ -829,6 +862,34 @@ function normalizeName(value: string) {
return value.toLowerCase().replaceAll("_", "");
}
function isStopSendingError(error: unknown) {
if (typeof error === "string") {
return error.includes("STOP_SENDING");
}
if (error instanceof Error) {
if (error.message.includes("STOP_SENDING")) {
return true;
}
const errorWithCause = error as Error & { cause?: unknown };
if (errorWithCause.cause !== undefined) {
return isStopSendingError(errorWithCause.cause);
}
return false;
}
if (typeof error === "object" && error !== null) {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === "string") {
return maybeMessage.includes("STOP_SENDING");
}
}
return false;
}
function coercePayload(value: unknown): Record<string, unknown> {
if (!isPlainObject(value)) {
throw new Error("Protocol payload must be a plain object");
@ -1175,7 +1236,9 @@ function encodeArrayPayload(
if (!isBoolKindMarker(encodedItem.kind)) {
if (encodedItem.payload.byteLength > 0xffff) {
throw new Error(`Array item at "${path}" is too large for protocol encoding`);
throw new Error(
`Array item at "${path}" is too large for protocol encoding`,
);
}
totalLength += 2 + encodedItem.payload.byteLength;
@ -1222,10 +1285,13 @@ function encodeContainerPayload(value: Record<string, unknown>, path: string) {
);
if (entries.length > 0xffff) {
throw new Error(`Container at "${path}" has too many entries for protocol encoding`);
throw new Error(
`Container at "${path}" has too many entries for protocol encoding`,
);
}
const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> = [];
const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> =
[];
let totalLength = 2;
for (const [name, entry] of entries) {
@ -1242,7 +1308,9 @@ function encodeContainerPayload(value: Record<string, unknown>, path: string) {
totalLength += 2;
} else {
if (encodedValue.payload.byteLength > 0xffff) {
throw new Error(`Container entry "${pathForEntry}" is too large for protocol encoding`);
throw new Error(
`Container entry "${pathForEntry}" is too large for protocol encoding`,
);
}
totalLength += 4 + encodedValue.payload.byteLength;
@ -1365,7 +1433,10 @@ function decodeContainerPayload(reader: ByteReader) {
);
}
value[key] = normalizeIncomingValue(key, decodeValuePayload(marker, payload));
value[key] = normalizeIncomingValue(
key,
decodeValuePayload(marker, payload),
);
}
return value;
@ -1382,7 +1453,8 @@ function getDataTypeNameByIndex(index: number) {
function isBoolKindMarker(marker: number) {
return (
marker === DATA_VALUE_KIND_BOOL_TRUE || marker === DATA_VALUE_KIND_BOOL_FALSE
marker === DATA_VALUE_KIND_BOOL_TRUE ||
marker === DATA_VALUE_KIND_BOOL_FALSE
);
}