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
+142
View File
@@ -0,0 +1,142 @@
// Command wasm is the TinyGo WebAssembly entry point for the Galaxy UI
// client. It exposes a small "compute boundary" API on
// `globalThis.galaxyCore` so the TypeScript-side `WasmCore` adapter can
// call into the Go canonical-bytes serializer and signature verifier
// without duplicating the contract in JavaScript.
//
// Public surface (all functions live under `globalThis.galaxyCore`):
//
// - signRequest(fields) -> Uint8Array
// Returns the canonical bytes for a v1 request envelope. The actual
// Ed25519 signing happens outside WASM (Phase 6 introduces WebCrypto
// with non-exportable keys).
// - verifyResponse(publicKey, signature, fields) -> boolean
// - verifyEvent(publicKey, signature, fields) -> boolean
// - verifyPayloadHash(payloadBytes, payloadHash) -> boolean
//
// Field objects are plain JS objects with camelCase keys matching the
// TypeScript `Core` interface, and bytes fields are Uint8Array.
// Timestamps are JS Number (Unix milliseconds fit in 53 bits well past
// year 2200).
//
// All functions return either a Uint8Array, a boolean, or fail closed.
// They never throw — callers may inspect the boolean result or rely on
// the canon-byte length to detect malformed input.
package main
import (
"syscall/js"
"galaxy/core/canon"
)
func main() {
js.Global().Set("galaxyCore", js.ValueOf(map[string]any{
"signRequest": js.FuncOf(signRequest),
"verifyResponse": js.FuncOf(verifyResponse),
"verifyEvent": js.FuncOf(verifyEvent),
"verifyPayloadHash": js.FuncOf(verifyPayloadHash),
}))
// Block forever so the Go runtime stays alive while JS keeps calling
// the registered functions.
select {}
}
func signRequest(_ js.Value, args []js.Value) any {
if len(args) != 1 {
return js.Null()
}
fields := canon.RequestSigningFields{
ProtocolVersion: args[0].Get("protocolVersion").String(),
DeviceSessionID: args[0].Get("deviceSessionId").String(),
MessageType: args[0].Get("messageType").String(),
TimestampMS: int64(args[0].Get("timestampMs").Float()),
RequestID: args[0].Get("requestId").String(),
PayloadHash: copyBytesFromJS(args[0].Get("payloadHash")),
}
return copyBytesToJS(canon.BuildRequestSigningInput(fields))
}
func verifyResponse(_ js.Value, args []js.Value) any {
if len(args) != 3 {
return js.ValueOf(false)
}
fields := canon.ResponseSigningFields{
ProtocolVersion: args[2].Get("protocolVersion").String(),
RequestID: args[2].Get("requestId").String(),
TimestampMS: int64(args[2].Get("timestampMs").Float()),
ResultCode: args[2].Get("resultCode").String(),
PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")),
}
publicKey := copyBytesFromJS(args[0])
signature := copyBytesFromJS(args[1])
if err := canon.VerifyResponseSignature(publicKey, signature, fields); err != nil {
return js.ValueOf(false)
}
return js.ValueOf(true)
}
func verifyEvent(_ js.Value, args []js.Value) any {
if len(args) != 3 {
return js.ValueOf(false)
}
fields := canon.EventSigningFields{
EventType: args[2].Get("eventType").String(),
EventID: args[2].Get("eventId").String(),
TimestampMS: int64(args[2].Get("timestampMs").Float()),
RequestID: args[2].Get("requestId").String(),
TraceID: args[2].Get("traceId").String(),
PayloadHash: copyBytesFromJS(args[2].Get("payloadHash")),
}
publicKey := copyBytesFromJS(args[0])
signature := copyBytesFromJS(args[1])
if err := canon.VerifyEventSignature(publicKey, signature, fields); err != nil {
return js.ValueOf(false)
}
return js.ValueOf(true)
}
func verifyPayloadHash(_ js.Value, args []js.Value) any {
if len(args) != 2 {
return js.ValueOf(false)
}
payloadBytes := copyBytesFromJS(args[0])
payloadHash := copyBytesFromJS(args[1])
if err := canon.VerifyPayloadHash(payloadBytes, payloadHash); err != nil {
return js.ValueOf(false)
}
return js.ValueOf(true)
}
// copyBytesFromJS materialises a JS Uint8Array (or any indexable
// byte-shaped value) into a Go byte slice. We avoid `js.CopyBytesToGo`
// because TinyGo's implementation panics on values it does not
// recognise as Uint8Array — a check that misfires for Uint8Arrays
// constructed from Node's Buffer in Vitest's jsdom environment. The
// per-element copy is slower but always correct, and the canonical
// envelope payloads are small enough (≤ a few hundred bytes) that the
// difference is negligible.
func copyBytesFromJS(value js.Value) []byte {
if value.IsUndefined() || value.IsNull() {
return nil
}
length := value.Length()
if length == 0 {
return nil
}
dst := make([]byte, length)
for i := 0; i < length; i++ {
dst[i] = byte(value.Index(i).Int())
}
return dst
}
// copyBytesToJS allocates a JS Uint8Array of the same length as src and
// copies src into it. The result is safe to hand back across the
// JS/Go boundary as the canonical-bytes return value.
func copyBytesToJS(src []byte) js.Value {
dst := js.Global().Get("Uint8Array").New(len(src))
js.CopyBytesToJS(dst, src)
return dst
}