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,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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user