phase 5: wasm core, GalaxyClient skeleton, Connect-Web stubs

Compile `ui/core` to WebAssembly via TinyGo (903 KB) and expose four
canonical-bytes / signature-verification functions on
`globalThis.galaxyCore` from `ui/wasm/main.go`. The TypeScript-side
`Core` interface plus a `WasmCore` adapter (browser + JSDOM loader)
bridge those into a typed shape, and a `GalaxyClient` skeleton wires
`Core.signRequest` → injected `Signer` → typed Connect client →
`Core.verifyPayloadHash` / `verifyResponse`.

Wire `ui/buf.gen.yaml` against the local
`@bufbuild/protoc-gen-es` v2 binary (devDependency) so the codegen
step does not depend on the buf.build BSR. Vitest covers the bridge
end-to-end: per-method WasmCore tests under JSDOM, byte-for-byte
canon parity against the gateway fixtures committed in Phase 3, and
a `GalaxyClient` orchestration test using
`createRouterTransport`. The committed `core.wasm` snapshot tracks
TinyGo output so contributors run `make wasm` only when `ui/core/`
changes; CI consumes the snapshot directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 12:58:37 +02:00
parent cd61868881
commit fbc0260720
25 changed files with 7284 additions and 36 deletions
+85
View File
@@ -0,0 +1,85 @@
// `Core` is the typed TypeScript view of the Galaxy compute boundary
// (`ui/core` Go module compiled to WASM/native). It only carries
// canonical-bytes serialization and signature verification — no
// network I/O, no key storage, no signing private keys. Real Ed25519
// signing happens in a platform-specific `Signer` (Phase 6 introduces
// WebCrypto for the web target).
//
// All byte fields are `Uint8Array`. Timestamps are `bigint` to keep
// nanosecond-resolution-friendly headroom and to mirror the v2
// Protobuf-ES `int64 → bigint` mapping used by the generated stubs.
export interface RequestSigningFields {
protocolVersion: string;
deviceSessionId: string;
messageType: string;
timestampMs: bigint;
requestId: string;
payloadHash: Uint8Array;
}
export interface ResponseSigningFields {
protocolVersion: string;
requestId: string;
timestampMs: bigint;
resultCode: string;
payloadHash: Uint8Array;
}
export interface EventSigningFields {
eventType: string;
eventId: string;
timestampMs: bigint;
requestId: string;
traceId: string;
payloadHash: Uint8Array;
}
export interface Core {
/**
* signRequest returns the canonical signing input bytes for a v1
* request envelope. Callers feed the result into a platform `Signer`
* (e.g. WebCrypto) to produce the actual Ed25519 signature.
*/
signRequest(fields: RequestSigningFields): Uint8Array;
/**
* verifyResponse authenticates a server response signature against
* the canonical v1 response signing input.
*/
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: ResponseSigningFields,
): boolean;
/**
* verifyEvent authenticates a push-event signature against the
* canonical v1 event signing input.
*/
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: EventSigningFields,
): boolean;
/**
* verifyPayloadHash returns true when payloadHash is the raw 32-byte
* SHA-256 digest of payloadBytes.
*/
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
}
export type CoreLoader = () => Promise<Core>;
import { loadWasmCore } from "./wasm";
/**
* loadCore resolves the Core implementation appropriate for the current
* build target. Phase 5 ships only the WASM adapter; later phases plug
* `WailsCore` and `CapacitorCore` here behind a build-time selector.
*/
export const loadCore: CoreLoader = loadWasmCore;
+196
View File
@@ -0,0 +1,196 @@
// `WasmCore` is the browser-side `Core` implementation. It loads the
// TinyGo-built `core.wasm` module + the matching `wasm_exec.js` runtime
// shim and bridges the four exported functions on `globalThis.galaxyCore`
// to the typed `Core` interface.
//
// The same module also runs under Node/JSDOM (via Vitest) by reading
// the bundle from disk; the test loader lives in
// `tests/setup-wasm.ts`. The browser path is the production target
// served from `static/core.wasm`.
import type {
Core,
EventSigningFields,
RequestSigningFields,
ResponseSigningFields,
} from "./index";
/**
* GalaxyCoreBridge is the shape Go installs on `globalThis.galaxyCore`.
* It is purely an internal contract between `ui/wasm/main.go` and this
* adapter; consumers see only the typed `Core` interface.
*/
interface GalaxyCoreBridge {
signRequest(fields: BridgeRequestFields): Uint8Array;
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: BridgeResponseFields,
): boolean;
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: BridgeEventFields,
): boolean;
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
}
interface BridgeRequestFields {
protocolVersion: string;
deviceSessionId: string;
messageType: string;
timestampMs: number;
requestId: string;
payloadHash: Uint8Array;
}
interface BridgeResponseFields {
protocolVersion: string;
requestId: string;
timestampMs: number;
resultCode: string;
payloadHash: Uint8Array;
}
interface BridgeEventFields {
eventType: string;
eventId: string;
timestampMs: number;
requestId: string;
traceId: string;
payloadHash: Uint8Array;
}
declare global {
// eslint-disable-next-line no-var
var galaxyCore: GalaxyCoreBridge | undefined;
// eslint-disable-next-line no-var
var Go: { new (): GoRuntime } | undefined;
}
interface GoRuntime {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
}
/**
* loadWasmCore boots the TinyGo `core.wasm` module in the browser and
* returns a `Core` whose methods bridge into the Go-exported functions.
* The module is started exactly once per page; subsequent calls return
* the cached instance. The browser path expects `wasm_exec.js` to be
* served alongside `core.wasm` under SvelteKit's `static/` root.
*/
let cached: Promise<Core> | undefined;
export function loadWasmCore(): Promise<Core> {
if (!cached) {
cached = bootBrowserWasm();
}
return cached;
}
async function bootBrowserWasm(): Promise<Core> {
if (typeof window === "undefined") {
throw new Error(
"loadWasmCore: no `window` global; load via tests/setup-wasm.ts under JSDOM",
);
}
await ensureGoRuntimeLoaded();
const Go = globalThis.Go;
if (!Go) {
throw new Error("loadWasmCore: Go runtime missing after wasm_exec.js load");
}
const go = new Go();
const response = await fetch("/core.wasm");
const bytes = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, go.importObject);
void go.run(instance);
return adaptBridge(requireBridge());
}
async function ensureGoRuntimeLoaded(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return;
}
await new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = "/wasm_exec.js";
script.onload = () => resolve();
script.onerror = () => reject(new Error("failed to load /wasm_exec.js"));
document.head.appendChild(script);
});
}
/**
* adaptBridge wraps the raw Go-installed bridge with the `Core` shape,
* normalising `bigint` ↔ JS Number conversion at the timestamp boundary.
* The bridge is also exported for the JSDOM test loader, which boots
* the WASM module differently (see `tests/setup-wasm.ts`).
*/
export function adaptBridge(bridge: GalaxyCoreBridge): Core {
return {
signRequest(fields: RequestSigningFields): Uint8Array {
return bridge.signRequest({
protocolVersion: fields.protocolVersion,
deviceSessionId: fields.deviceSessionId,
messageType: fields.messageType,
timestampMs: bigintToNumber(fields.timestampMs),
requestId: fields.requestId,
payloadHash: fields.payloadHash,
});
},
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: ResponseSigningFields,
): boolean {
return bridge.verifyResponse(publicKey, signature, {
protocolVersion: fields.protocolVersion,
requestId: fields.requestId,
timestampMs: bigintToNumber(fields.timestampMs),
resultCode: fields.resultCode,
payloadHash: fields.payloadHash,
});
},
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: EventSigningFields,
): boolean {
return bridge.verifyEvent(publicKey, signature, {
eventType: fields.eventType,
eventId: fields.eventId,
timestampMs: bigintToNumber(fields.timestampMs),
requestId: fields.requestId,
traceId: fields.traceId,
payloadHash: fields.payloadHash,
});
},
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean {
return bridge.verifyPayloadHash(payloadBytes, payloadHash);
},
};
}
export function requireBridge(): GalaxyCoreBridge {
const bridge = globalThis.galaxyCore;
if (!bridge) {
throw new Error(
"loadWasmCore: `globalThis.galaxyCore` not installed — was the WASM module instantiated?",
);
}
return bridge;
}
// Unix-millisecond timestamps fit in 53 bits well past the year 2200, so
// the bigint → number narrowing is safe for every realistic value the
// envelope contract carries.
function bigintToNumber(value: bigint): number {
return Number(value);
}