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:
@@ -0,0 +1,7 @@
|
||||
module galaxy/ui/wasm
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require galaxy/core v0.0.0
|
||||
|
||||
replace galaxy/core => ../core
|
||||
@@ -0,0 +1,8 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+142
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user