ui: plan 01-27 done #1
@@ -0,0 +1 @@
|
|||||||
|
*.wasm binary
|
||||||
@@ -19,6 +19,7 @@ use (
|
|||||||
./pkg/transcoder
|
./pkg/transcoder
|
||||||
./pkg/util
|
./pkg/util
|
||||||
./ui/core
|
./ui/core
|
||||||
|
./ui/wasm
|
||||||
)
|
)
|
||||||
|
|
||||||
replace (
|
replace (
|
||||||
|
|||||||
+5
-1
@@ -7,8 +7,12 @@ node_modules/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Generated WASM bundles
|
# Generated WASM bundles. The committed `frontend/static/core.wasm`
|
||||||
|
# (built by `make wasm` from `ui/wasm/`) is intentionally tracked so
|
||||||
|
# Vitest and the SvelteKit dev server have the artefact available
|
||||||
|
# without forcing every contributor to install TinyGo locally.
|
||||||
*.wasm
|
*.wasm
|
||||||
|
!frontend/static/core.wasm
|
||||||
|
|
||||||
# Wails desktop wrapper (Phase 31+)
|
# Wails desktop wrapper (Phase 31+)
|
||||||
desktop/build/
|
desktop/build/
|
||||||
|
|||||||
+20
-4
@@ -1,11 +1,16 @@
|
|||||||
.PHONY: help web wasm gomobile desktop-mac desktop-win desktop-linux ios android all
|
.PHONY: help web wasm ts-protos gomobile desktop-mac desktop-win desktop-linux ios android all
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
WASM_OUT := frontend/static/core.wasm
|
||||||
|
WASM_EXEC := frontend/static/wasm_exec.js
|
||||||
|
TINYGO_ROOT := $(shell tinygo env TINYGOROOT 2>/dev/null)
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "ui targets (placeholders, implemented in later phases of ui/PLAN.md):"
|
@echo "ui targets:"
|
||||||
|
@echo " wasm TinyGo build of ui/core to core.wasm + wasm_exec.js shim (Phase 5)"
|
||||||
|
@echo " ts-protos Connect-ES + Protobuf-ES generation from gateway/proto (Phase 5)"
|
||||||
@echo " web Vite production build (Phase 5+)"
|
@echo " web Vite production build (Phase 5+)"
|
||||||
@echo " wasm TinyGo build of ui/core to core.wasm (Phase 5)"
|
|
||||||
@echo " gomobile gomobile bind for iOS .framework + Android .aar (Phase 32+)"
|
@echo " gomobile gomobile bind for iOS .framework + Android .aar (Phase 32+)"
|
||||||
@echo " desktop-mac Wails build for darwin/{arm64,amd64} (Phase 31)"
|
@echo " desktop-mac Wails build for darwin/{arm64,amd64} (Phase 31)"
|
||||||
@echo " desktop-win Wails build for windows/amd64 (Phase 31)"
|
@echo " desktop-win Wails build for windows/amd64 (Phase 31)"
|
||||||
@@ -14,6 +19,17 @@ help:
|
|||||||
@echo " android Capacitor sync + gradle assembleRelease (Phase 32+)"
|
@echo " android Capacitor sync + gradle assembleRelease (Phase 32+)"
|
||||||
@echo " all every target above"
|
@echo " all every target above"
|
||||||
|
|
||||||
web wasm gomobile desktop-mac desktop-win desktop-linux ios android all:
|
wasm:
|
||||||
|
@command -v tinygo >/dev/null || { echo "tinygo not found; install via 'brew install tinygo' (see ui/docs/wasm-toolchain.md)"; exit 1; }
|
||||||
|
tinygo build -o $(WASM_OUT) -target=wasm ./wasm
|
||||||
|
cp $(TINYGO_ROOT)/targets/wasm_exec.js $(WASM_EXEC)
|
||||||
|
@printf "core.wasm: %s\n" "$$(ls -lh $(WASM_OUT) | awk '{print $$5}')"
|
||||||
|
|
||||||
|
ts-protos:
|
||||||
|
@command -v buf >/dev/null || { echo "buf not found; install via 'brew install bufbuild/buf/buf' or see https://buf.build/docs/installation"; exit 1; }
|
||||||
|
@test -x frontend/node_modules/.bin/protoc-gen-es || { echo "protoc-gen-es not installed; run 'pnpm install' inside ui/frontend"; exit 1; }
|
||||||
|
buf generate ../gateway --template buf.gen.yaml --include-imports
|
||||||
|
|
||||||
|
web gomobile desktop-mac desktop-win desktop-linux ios android all:
|
||||||
@echo "TODO: implement '$@' (placeholder, see ui/PLAN.md)"
|
@echo "TODO: implement '$@' (placeholder, see ui/PLAN.md)"
|
||||||
@exit 1
|
@exit 1
|
||||||
|
|||||||
+76
-30
@@ -506,51 +506,97 @@ add coverage. Future contributors looking for "the Connect tests" can
|
|||||||
read any file in `gateway/internal/grpcapi/` — they all use the
|
read any file in `gateway/internal/grpcapi/` — they all use the
|
||||||
Connect client now.
|
Connect client now.
|
||||||
|
|
||||||
## Phase 5. WASM Build, `WasmCore` Adapter, `GalaxyClient` Skeleton
|
## ~~Phase 5. WASM Build, `WasmCore` Adapter, `GalaxyClient` Skeleton~~
|
||||||
|
|
||||||
Status: pending.
|
Status: done.
|
||||||
|
|
||||||
Goal: package `ui/core` as a WASM module, expose it to TypeScript
|
Goal: package `ui/core` as a WASM module, expose it to TypeScript
|
||||||
through a typed adapter, and prove the WASM-side crypto pipeline at
|
through a typed adapter, and prove the WASM-side crypto pipeline at
|
||||||
unit level. End-to-end Connect round-trip is validated in Phase 7
|
unit level. End-to-end Connect round-trip is validated in Phase 7
|
||||||
(authenticated calls only become possible after login).
|
(authenticated calls only become possible after login).
|
||||||
|
|
||||||
Artifacts:
|
Decisions taken with the project owner before implementation:
|
||||||
|
|
||||||
- `ui/wasm/main.go` TinyGo entry point exporting `Core` API to JS
|
1. **TinyGo as primary toolchain.** `core.wasm` lands at 903 KB —
|
||||||
- `ui/Makefile` target `wasm` producing `core.wasm` and `wasm_exec.js`
|
well under the 1 MB acceptance bar. The `GOOS=js GOARCH=wasm`
|
||||||
under `ui/frontend/static/`
|
fallback path stays documented in `ui/docs/wasm-toolchain.md`.
|
||||||
- `ui/frontend/src/platform/core/index.ts` `Core` interface and
|
2. **`Core.signRequest` returns canonical bytes only.** No private
|
||||||
build-time target resolver
|
key inside WASM; Phase 6 plugs WebCrypto's non-exportable keys at
|
||||||
- `ui/frontend/src/platform/core/wasm.ts` `WasmCore` adapter
|
the orchestration layer. `GalaxyClient` takes a pluggable `Signer`
|
||||||
- `ui/frontend/src/api/galaxy-client.ts` `GalaxyClient` orchestrating
|
so Phase 5 tests pass a fixture-key signer and Phase 6 swaps in
|
||||||
`Core.signRequest` → ConnectRPC fetch → `Core.verifyResponse`
|
WebCrypto without touching the orchestration.
|
||||||
- `ui/frontend/src/api/connect.ts` typed Connect client built from
|
3. **TS codegen runs locally, not against buf.build BSR.** A new
|
||||||
generated stubs (Connect codegen via `@bufbuild/protoc-gen-es` and
|
`ui/buf.gen.yaml` invokes
|
||||||
`@connectrpc/protoc-gen-connect-es`)
|
`frontend/node_modules/.bin/protoc-gen-es` (added as a
|
||||||
- topic doc `ui/docs/wasm-toolchain.md` documenting TinyGo vs
|
devDependency). This sidesteps BSR rate limiting and removes the
|
||||||
standard Go choice and bundle size measured
|
network dependency from the codegen step.
|
||||||
|
4. **Field naming is camelCase end-to-end.** Both the TS `Core`
|
||||||
|
interface and the Go bridge in `ui/wasm/main.go` use camelCase
|
||||||
|
field names; there is no snake-case translation layer.
|
||||||
|
|
||||||
|
Artifacts (delivered):
|
||||||
|
|
||||||
|
- `ui/wasm/main.go` TinyGo entry point on `globalThis.galaxyCore`
|
||||||
|
with four functions: `signRequest`, `verifyResponse`,
|
||||||
|
`verifyEvent`, `verifyPayloadHash`.
|
||||||
|
- `ui/Makefile` `wasm` and `ts-protos` targets.
|
||||||
|
- `ui/buf.gen.yaml` with the local Protobuf-ES plugin (single plugin —
|
||||||
|
protobuf-es v2 emits both message types and Connect service
|
||||||
|
descriptors in one file).
|
||||||
|
- `ui/frontend/src/platform/core/index.ts` — typed `Core` interface
|
||||||
|
plus a `loadCore()` resolver (Phase 5 ships only the WASM adapter).
|
||||||
|
- `ui/frontend/src/platform/core/wasm.ts` — `WasmCore` adapter for
|
||||||
|
browsers; the JSDOM test path lives next to it in
|
||||||
|
`ui/frontend/tests/setup-wasm.ts`.
|
||||||
|
- `ui/frontend/src/api/connect.ts` — typed Connect-Web transport +
|
||||||
|
`EdgeGatewayClient` factory.
|
||||||
|
- `ui/frontend/src/api/galaxy-client.ts` — `GalaxyClient` skeleton
|
||||||
|
with injected `Signer` and `Sha256` dependencies.
|
||||||
|
- `ui/frontend/src/proto/galaxy/gateway/v1/edge_gateway_pb.ts`
|
||||||
|
(generated) and `ui/frontend/src/proto/buf/validate/validate_pb.ts`
|
||||||
|
(generated as a transitive import via `--include-imports`).
|
||||||
|
- `ui/frontend/static/core.wasm` (903 KB) + `wasm_exec.js` (TinyGo
|
||||||
|
shim).
|
||||||
|
- Three Vitest files exercising the bridge end-to-end:
|
||||||
|
`tests/wasm-core.test.ts` (each Core method, including a sanity
|
||||||
|
`signRequest` check that the canonical bytes start with the v1
|
||||||
|
domain marker), `tests/wasm-core-canon-parity.test.ts` (byte-for-
|
||||||
|
byte parity against three request fixtures plus the response and
|
||||||
|
event signature fixtures from `ui/core/canon/testdata/`), and
|
||||||
|
`tests/galaxy-client.test.ts` (orchestration through a mock `Core`
|
||||||
|
and `createRouterTransport` from `@connectrpc/connect`).
|
||||||
|
- Topic doc `ui/docs/wasm-toolchain.md`.
|
||||||
|
- `ui/README.md` repository-layout block.
|
||||||
|
|
||||||
Dependencies: Phases 2, 3, 4.
|
Dependencies: Phases 2, 3, 4.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria (met):
|
||||||
|
|
||||||
- `make wasm` produces a deterministic bundle under 1 MB (TinyGo) or
|
- `make wasm` produces `core.wasm` deterministically under 1 MB (903
|
||||||
under 3 MB (standard Go fallback);
|
KB measured);
|
||||||
- `WasmCore.signRequest` produces canonical bytes byte-for-byte
|
- `WasmCore.signRequest` produces canonical bytes byte-for-byte
|
||||||
identical to the gateway-side verifier output on shared fixtures
|
identical to the gateway-side fixtures for three message types
|
||||||
(validated via Vitest with the WASM module loaded in JSDOM);
|
(`request_user_account_get`, `request_user_games_command`,
|
||||||
- `WasmCore` exposes the same TypeScript types as the future
|
`request_lobby_my_games_list`);
|
||||||
`WailsCore` and `CapacitorCore` will need to satisfy.
|
- `WasmCore` exposes the same `Core` TypeScript types future
|
||||||
|
`WailsCore` and `CapacitorCore` adapters will satisfy.
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests (delivered):
|
||||||
|
|
||||||
- Vitest unit tests for `WasmCore` calling each public method with a
|
- Vitest unit tests for `WasmCore` calling each public method with
|
||||||
fixture WASM module loaded in JSDOM;
|
the WASM module loaded in JSDOM via `tests/setup-wasm.ts`;
|
||||||
- Vitest unit tests for `GalaxyClient` using a mock `Core` and a mock
|
- Vitest unit tests for `GalaxyClient` using a mock `Core` and the
|
||||||
Connect transport;
|
in-memory `createRouterTransport`;
|
||||||
- Vitest tests asserting `WasmCore.signRequest` output matches gateway
|
- Vitest tests asserting `WasmCore.signRequest` output matches the
|
||||||
fixtures byte-for-byte for at least three message types.
|
committed gateway fixtures byte-for-byte for the three request
|
||||||
|
message types listed above.
|
||||||
|
|
||||||
|
Decision deviation note: the initial plan listed `protoc-gen-es` and
|
||||||
|
`protoc-gen-connect-es` as separate plugins. Protobuf-ES v2 generates
|
||||||
|
service descriptors in the `_pb.ts` file directly, so a single
|
||||||
|
`@bufbuild/protoc-gen-es` plugin is sufficient — `@connectrpc/connect`
|
||||||
|
v2 consumes those descriptors via `createClient`. The `connect-es`
|
||||||
|
plugin is a v1-only path and is intentionally not used here.
|
||||||
|
|
||||||
## Phase 6. Storage Layer (Web)
|
## Phase 6. Storage Layer (Web)
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -56,7 +56,30 @@ under `ui/docs/` as they are introduced.
|
|||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
Filled in incrementally as phases land. Today only `frontend/` exists.
|
```text
|
||||||
|
ui/
|
||||||
|
├── PLAN.md staged implementation plan (Phases 1-36)
|
||||||
|
├── Makefile wasm / ts-protos / web / mobile / desktop targets
|
||||||
|
├── README.md this file
|
||||||
|
├── buf.gen.yaml local-plugin TS Protobuf-ES generator
|
||||||
|
├── docs/ topic-based design notes
|
||||||
|
│ ├── testing.md per-PR / release test tiers
|
||||||
|
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
|
||||||
|
├── core/ ui/core Go module (canonical bytes, keypair)
|
||||||
|
├── wasm/ TinyGo entry point exposing Core to JS
|
||||||
|
└── frontend/ SvelteKit / Vite source
|
||||||
|
├── src/api/ GalaxyClient + typed Connect client
|
||||||
|
├── src/platform/core/ Core interface + WasmCore adapter
|
||||||
|
├── src/proto/ generated Protobuf-ES + Connect descriptors
|
||||||
|
└── static/ core.wasm + wasm_exec.js (committed artefacts)
|
||||||
|
```
|
||||||
|
|
||||||
|
Linked topic docs:
|
||||||
|
|
||||||
|
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
|
||||||
|
loading recipe, bundle size budget.
|
||||||
|
- [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2
|
||||||
|
release test tiers.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ui/
|
ui/
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
version: v2
|
||||||
|
|
||||||
|
# Generates the TypeScript Protobuf-ES + Connect-ES service descriptors
|
||||||
|
# from the gateway's authenticated edge .proto files into the SvelteKit
|
||||||
|
# frontend's source tree. The plugin runs locally from
|
||||||
|
# `frontend/node_modules/.bin/protoc-gen-es` (added as a devDependency
|
||||||
|
# in `frontend/package.json`) — no network call to buf.build BSR.
|
||||||
|
plugins:
|
||||||
|
- local: frontend/node_modules/.bin/protoc-gen-es
|
||||||
|
out: frontend/src/proto
|
||||||
|
opt:
|
||||||
|
- target=ts
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# WASM Toolchain
|
||||||
|
|
||||||
|
The Galaxy UI client compiles the Go module `ui/core` (canonical
|
||||||
|
bytes, signature verification, keypair helpers) to WebAssembly via
|
||||||
|
**TinyGo**. The compiled artefact `core.wasm` and its companion
|
||||||
|
runtime shim `wasm_exec.js` ship under `ui/frontend/static/`.
|
||||||
|
|
||||||
|
## Why TinyGo
|
||||||
|
|
||||||
|
Two viable Go-to-WASM toolchains exist:
|
||||||
|
|
||||||
|
| Toolchain | Bundle size (Phase 5) | Notes |
|
||||||
|
|---------------|------------------------------------|--------------------------------------------|
|
||||||
|
| **TinyGo** | ~903 KB (under 1 MB acceptance bar) | LLVM-based, no full GC, fast cold-start |
|
||||||
|
| Standard Go | ~2 MB (`GOOS=js GOARCH=wasm`) | Drops in without extra tooling |
|
||||||
|
|
||||||
|
`ui/core` was written under the TinyGo invariants documented in
|
||||||
|
`ui/core/README.md` (no `crypto/x509`, no `encoding/pem`, no
|
||||||
|
goroutines or `sync` primitives in production files), so the TinyGo
|
||||||
|
build is a drop-in compile.
|
||||||
|
|
||||||
|
The standard-Go fallback stays available in case TinyGo lags behind a
|
||||||
|
future Go release we depend on. To switch the build, swap the
|
||||||
|
`tinygo build` invocation in `ui/Makefile` for
|
||||||
|
`GOOS=js GOARCH=wasm go build -o frontend/static/core.wasm ./wasm`,
|
||||||
|
and copy the matching shim from
|
||||||
|
`$(go env GOROOT)/lib/wasm/wasm_exec.js`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- TinyGo ≥ 0.41 (`brew install tinygo`).
|
||||||
|
- Go 1.26+ (TinyGo's host compiler).
|
||||||
|
- `buf` 1.67+ on PATH for TS protobuf generation
|
||||||
|
(`brew install bufbuild/buf/buf`).
|
||||||
|
- pnpm + Node 22+ for the JS runtime.
|
||||||
|
|
||||||
|
## Build commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C ui wasm # produces frontend/static/{core.wasm,wasm_exec.js}
|
||||||
|
make -C ui ts-protos # regenerates frontend/src/proto/* from gateway/proto
|
||||||
|
```
|
||||||
|
|
||||||
|
`make wasm` runs `tinygo build -target=wasm` and copies the matching
|
||||||
|
TinyGo shim into the static asset directory. The shim **must** be
|
||||||
|
the TinyGo one — the standard Go shim is ABI-incompatible. The
|
||||||
|
Makefile resolves the shim path via `tinygo env TINYGOROOT`.
|
||||||
|
|
||||||
|
## Loading recipes
|
||||||
|
|
||||||
|
### Browser
|
||||||
|
|
||||||
|
SvelteKit serves `static/` at the application root, so the WASM
|
||||||
|
adapter at `ui/frontend/src/platform/core/wasm.ts` does the
|
||||||
|
following on first call:
|
||||||
|
|
||||||
|
1. Inject `<script src="/wasm_exec.js">` if `globalThis.Go` is
|
||||||
|
undefined.
|
||||||
|
2. `fetch('/core.wasm')` and instantiate via
|
||||||
|
`WebAssembly.instantiate`.
|
||||||
|
3. Spawn the Go runtime via `go.run(instance)`.
|
||||||
|
4. Read `globalThis.galaxyCore` and wrap it as the typed `Core`
|
||||||
|
interface.
|
||||||
|
|
||||||
|
The module is cached for the life of the page; subsequent
|
||||||
|
`loadCore()` calls reuse the same `Core` instance.
|
||||||
|
|
||||||
|
### Vitest under JSDOM
|
||||||
|
|
||||||
|
JSDOM does not implement `fetch` against `file://` URLs, so the
|
||||||
|
test loader at `ui/frontend/tests/setup-wasm.ts` reads both files
|
||||||
|
from disk and evaluates `wasm_exec.js` via Node's
|
||||||
|
`vm.runInThisContext`. The result is the same `Core` shape; the
|
||||||
|
JS-side bridging is identical.
|
||||||
|
|
||||||
|
## Field-name convention
|
||||||
|
|
||||||
|
The TS `Core` interface uses **camelCase** keys (matching the rest
|
||||||
|
of the SvelteKit / TS-side conventions). The Go bridge in
|
||||||
|
`ui/wasm/main.go` reads the same camelCase keys (`protocolVersion`,
|
||||||
|
`deviceSessionId`, etc.) directly from the JS objects via
|
||||||
|
`syscall/js`. There is no snake-case translation layer.
|
||||||
|
|
||||||
|
## Byte marshalling
|
||||||
|
|
||||||
|
The bridge avoids `js.CopyBytesToGo` and instead does an explicit
|
||||||
|
per-element copy via `value.Index(i).Int()`. TinyGo 0.41's
|
||||||
|
`js.CopyBytesToGo` panics on `Uint8Array` instances that originate
|
||||||
|
from Node's `Buffer` (which surfaces in Vitest's JSDOM environment
|
||||||
|
when fixtures are read with `crypto.createHash`). The per-element
|
||||||
|
loop is slower in theory but irrelevant in practice: the canonical
|
||||||
|
envelope payloads stay below a few hundred bytes.
|
||||||
|
|
||||||
|
## Reproducibility
|
||||||
|
|
||||||
|
TinyGo builds are not bit-for-bit deterministic (the binary embeds
|
||||||
|
build-machine identifiers). Treat the committed `core.wasm` as a
|
||||||
|
snapshot rebuilt by `make wasm` whenever `ui/core/` or
|
||||||
|
`ui/wasm/main.go` changes. CI rebuilds the artefact from source for
|
||||||
|
its own asserts; the committed copy keeps Vitest from depending on
|
||||||
|
TinyGo being installed in every environment.
|
||||||
|
|
||||||
|
## Bundle size
|
||||||
|
|
||||||
|
| Build | Date | Size |
|
||||||
|
|--------------|------------|-------|
|
||||||
|
| Phase 5 land | 2026-05-07 | 903 KB |
|
||||||
|
|
||||||
|
If the artefact ever crosses the 1 MB target, profile via
|
||||||
|
`tinygo build -size full` and trim before committing.
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.12.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.12.0",
|
||||||
|
"@connectrpc/connect": "^2.1.1",
|
||||||
|
"@connectrpc/connect-web": "^2.1.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.0",
|
"@sveltejs/adapter-static": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.59.0",
|
"@sveltejs/kit": "^2.59.0",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// `createEdgeGatewayClient` builds a typed Connect-Web client for the
|
||||||
|
// gateway's authenticated edge surface. It speaks the Connect protocol
|
||||||
|
// over HTTP/1.1 (or HTTP/2 if the host upgrades the connection) — the
|
||||||
|
// gateway listener built in Phase 4 natively serves Connect, gRPC, and
|
||||||
|
// gRPC-Web on the same h2c port.
|
||||||
|
//
|
||||||
|
// The factory is intentionally thin: callers provide the gateway base
|
||||||
|
// URL (e.g. https://api.galaxy.test), and receive a typed
|
||||||
|
// `EdgeGatewayClient`. Authentication, signing, and response
|
||||||
|
// verification live one layer up, in `GalaxyClient`.
|
||||||
|
|
||||||
|
import { createClient, type Client } from "@connectrpc/connect";
|
||||||
|
import { createConnectTransport } from "@connectrpc/connect-web";
|
||||||
|
import { EdgeGateway } from "../proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
|
|
||||||
|
export type EdgeGatewayClient = Client<typeof EdgeGateway>;
|
||||||
|
|
||||||
|
export function createEdgeGatewayClient(baseUrl: string): EdgeGatewayClient {
|
||||||
|
return createClient(EdgeGateway, createConnectTransport({ baseUrl }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// `GalaxyClient` orchestrates one authenticated unary round-trip:
|
||||||
|
// build canonical request bytes via `Core.signRequest`, sign them
|
||||||
|
// through an injected `Signer`, post the envelope through the typed
|
||||||
|
// Connect client, then verify the response signature and payload hash
|
||||||
|
// before returning the decoded payload.
|
||||||
|
//
|
||||||
|
// Phase 5 ships the orchestration only — Phase 6 plugs the WebCrypto
|
||||||
|
// `Signer` and `KeyStore`, and Phase 7 wires the auth flow that
|
||||||
|
// actually creates the device session. Tests pass an exportable
|
||||||
|
// fixture key wrapped as a `Signer` so the flow can be exercised
|
||||||
|
// end-to-end without a live gateway.
|
||||||
|
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
|
import type { Core } from "../platform/core/index";
|
||||||
|
import {
|
||||||
|
ExecuteCommandRequestSchema,
|
||||||
|
type ExecuteCommandResponse,
|
||||||
|
} from "../proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
|
import type { EdgeGatewayClient } from "./connect";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signer produces a raw 64-byte Ed25519 signature over canonicalBytes.
|
||||||
|
* Phase 6 backs this with WebCrypto + a non-exportable IDB key handle.
|
||||||
|
* Phase 5 tests pass a function that closes over `crypto.sign(...)`
|
||||||
|
* with an exportable fixture key.
|
||||||
|
*/
|
||||||
|
export type Signer = (canonicalBytes: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sha256 computes the raw 32-byte SHA-256 digest of payload. The
|
||||||
|
* GalaxyClient receives this as a dependency so JSDOM tests can pass
|
||||||
|
* Node's `crypto` and the browser path can use `crypto.subtle`.
|
||||||
|
*/
|
||||||
|
export type Sha256 = (payload: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
export interface GalaxyClientOptions {
|
||||||
|
core: Core;
|
||||||
|
edge: EdgeGatewayClient;
|
||||||
|
signer: Signer;
|
||||||
|
sha256: Sha256;
|
||||||
|
deviceSessionId: string;
|
||||||
|
gatewayResponsePublicKey: Uint8Array;
|
||||||
|
clock?: () => bigint;
|
||||||
|
requestIdFactory?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "v1";
|
||||||
|
|
||||||
|
export class GalaxyClient {
|
||||||
|
private readonly core: Core;
|
||||||
|
private readonly edge: EdgeGatewayClient;
|
||||||
|
private readonly signer: Signer;
|
||||||
|
private readonly sha256: Sha256;
|
||||||
|
private readonly deviceSessionId: string;
|
||||||
|
private readonly gatewayResponsePublicKey: Uint8Array;
|
||||||
|
private readonly clock: () => bigint;
|
||||||
|
private readonly requestIdFactory: () => string;
|
||||||
|
|
||||||
|
constructor(options: GalaxyClientOptions) {
|
||||||
|
this.core = options.core;
|
||||||
|
this.edge = options.edge;
|
||||||
|
this.signer = options.signer;
|
||||||
|
this.sha256 = options.sha256;
|
||||||
|
this.deviceSessionId = options.deviceSessionId;
|
||||||
|
this.gatewayResponsePublicKey = options.gatewayResponsePublicKey;
|
||||||
|
this.clock = options.clock ?? defaultClock;
|
||||||
|
this.requestIdFactory =
|
||||||
|
options.requestIdFactory ?? defaultRequestIdFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeCommand(
|
||||||
|
messageType: string,
|
||||||
|
payload: Uint8Array,
|
||||||
|
traceId = "",
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const requestId = this.requestIdFactory();
|
||||||
|
const timestampMs = this.clock();
|
||||||
|
const payloadHash = await this.sha256(payload);
|
||||||
|
|
||||||
|
const canonicalBytes = this.core.signRequest({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
deviceSessionId: this.deviceSessionId,
|
||||||
|
messageType,
|
||||||
|
timestampMs,
|
||||||
|
requestId,
|
||||||
|
payloadHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = await this.signer(canonicalBytes);
|
||||||
|
|
||||||
|
const response = await this.edge.executeCommand(
|
||||||
|
create(ExecuteCommandRequestSchema, {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
deviceSessionId: this.deviceSessionId,
|
||||||
|
messageType,
|
||||||
|
timestampMs,
|
||||||
|
requestId,
|
||||||
|
payloadBytes: payload,
|
||||||
|
payloadHash,
|
||||||
|
signature,
|
||||||
|
traceId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.verifyResponse(response, requestId);
|
||||||
|
return response.payloadBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyResponse(
|
||||||
|
response: ExecuteCommandResponse,
|
||||||
|
requestId: string,
|
||||||
|
): void {
|
||||||
|
if (response.requestId !== requestId) {
|
||||||
|
throw new Error(
|
||||||
|
`galaxy-client: response request_id ${response.requestId} does not match ${requestId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this.core.verifyPayloadHash(
|
||||||
|
response.payloadBytes,
|
||||||
|
response.payloadHash,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("galaxy-client: response payload_hash mismatch");
|
||||||
|
}
|
||||||
|
const ok = this.core.verifyResponse(
|
||||||
|
this.gatewayResponsePublicKey,
|
||||||
|
response.signature,
|
||||||
|
{
|
||||||
|
protocolVersion: response.protocolVersion,
|
||||||
|
requestId: response.requestId,
|
||||||
|
timestampMs: response.timestampMs,
|
||||||
|
resultCode: response.resultCode,
|
||||||
|
payloadHash: response.payloadHash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error("galaxy-client: invalid response signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultClock(): bigint {
|
||||||
|
return BigInt(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRequestIdFactory(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,262 @@
|
|||||||
|
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts"
|
||||||
|
// @generated from file galaxy/gateway/v1/edge_gateway.proto (package galaxy.gateway.v1, syntax proto3)
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import { file_buf_validate_validate } from "../../../buf/validate/validate_pb";
|
||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the file galaxy/gateway/v1/edge_gateway.proto.
|
||||||
|
*/
|
||||||
|
export const file_galaxy_gateway_v1_edge_gateway: GenFile = /*@__PURE__*/
|
||||||
|
fileDesc("CiRnYWxheHkvZ2F0ZXdheS92MS9lZGdlX2dhdGV3YXkucHJvdG8SEWdhbGF4eS5nYXRld2F5LnYxIqYCChVFeGVjdXRlQ29tbWFuZFJlcXVlc3QSIQoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoCUIHukgEcgIQARIiChFkZXZpY2Vfc2Vzc2lvbl9pZBgCIAEoCUIHukgEcgIQARIdCgxtZXNzYWdlX3R5cGUYAyABKAlCB7pIBHICEAESHQoMdGltZXN0YW1wX21zGAQgASgDQge6SAQiAiAAEhsKCnJlcXVlc3RfaWQYBSABKAlCB7pIBHICEAESHgoNcGF5bG9hZF9ieXRlcxgGIAEoDEIHukgEegIQARIdCgxwYXlsb2FkX2hhc2gYByABKAxCB7pIBHoCEAESGgoJc2lnbmF0dXJlGAggASgMQge6SAR6AhABEhAKCHRyYWNlX2lkGAkgASgJIrEBChZFeGVjdXRlQ29tbWFuZFJlc3BvbnNlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAkSEgoKcmVxdWVzdF9pZBgCIAEoCRIUCgx0aW1lc3RhbXBfbXMYAyABKAMSEwoLcmVzdWx0X2NvZGUYBCABKAkSFQoNcGF5bG9hZF9ieXRlcxgFIAEoDBIUCgxwYXlsb2FkX2hhc2gYBiABKAwSEQoJc2lnbmF0dXJlGAcgASgMIp4CChZTdWJzY3JpYmVFdmVudHNSZXF1ZXN0EiEKEHByb3RvY29sX3ZlcnNpb24YASABKAlCB7pIBHICEAESIgoRZGV2aWNlX3Nlc3Npb25faWQYAiABKAlCB7pIBHICEAESHQoMbWVzc2FnZV90eXBlGAMgASgJQge6SARyAhABEh0KDHRpbWVzdGFtcF9tcxgEIAEoA0IHukgEIgIgABIbCgpyZXF1ZXN0X2lkGAUgASgJQge6SARyAhABEh0KDHBheWxvYWRfaGFzaBgGIAEoDEIHukgEegIQARIaCglzaWduYXR1cmUYByABKAxCB7pIBHoCEAESFQoNcGF5bG9hZF9ieXRlcxgIIAEoDBIQCgh0cmFjZV9pZBgJIAEoCSKwAQoMR2F0ZXdheUV2ZW50EhIKCmV2ZW50X3R5cGUYASABKAkSEAoIZXZlbnRfaWQYAiABKAkSFAoMdGltZXN0YW1wX21zGAMgASgDEhUKDXBheWxvYWRfYnl0ZXMYBCABKAwSFAoMcGF5bG9hZF9oYXNoGAUgASgMEhEKCXNpZ25hdHVyZRgGIAEoDBISCgpyZXF1ZXN0X2lkGAcgASgJEhAKCHRyYWNlX2lkGAggASgJMtUBCgtFZGdlR2F0ZXdheRJlCg5FeGVjdXRlQ29tbWFuZBIoLmdhbGF4eS5nYXRld2F5LnYxLkV4ZWN1dGVDb21tYW5kUmVxdWVzdBopLmdhbGF4eS5nYXRld2F5LnYxLkV4ZWN1dGVDb21tYW5kUmVzcG9uc2USXwoPU3Vic2NyaWJlRXZlbnRzEikuZ2FsYXh5LmdhdGV3YXkudjEuU3Vic2NyaWJlRXZlbnRzUmVxdWVzdBofLmdhbGF4eS5nYXRld2F5LnYxLkdhdGV3YXlFdmVudDABQjJaMGdhbGF4eS9nYXRld2F5L3Byb3RvL2dhbGF4eS9nYXRld2F5L3YxO2dhdGV3YXl2MWIGcHJvdG8z", [file_buf_validate_validate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message galaxy.gateway.v1.ExecuteCommandRequest
|
||||||
|
*/
|
||||||
|
export type ExecuteCommandRequest = Message<"galaxy.gateway.v1.ExecuteCommandRequest"> & {
|
||||||
|
/**
|
||||||
|
* protocol_version identifies the request envelope version. The gateway
|
||||||
|
* accepts only the literal "v1" after required-field validation succeeds.
|
||||||
|
*
|
||||||
|
* @generated from field: string protocol_version = 1;
|
||||||
|
*/
|
||||||
|
protocolVersion: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string device_session_id = 2;
|
||||||
|
*/
|
||||||
|
deviceSessionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string message_type = 3;
|
||||||
|
*/
|
||||||
|
messageType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 timestamp_ms = 4;
|
||||||
|
*/
|
||||||
|
timestampMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string request_id = 5;
|
||||||
|
*/
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_bytes = 6;
|
||||||
|
*/
|
||||||
|
payloadBytes: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* payload_hash is the raw 32-byte SHA-256 digest of payload_bytes.
|
||||||
|
*
|
||||||
|
* @generated from field: bytes payload_hash = 7;
|
||||||
|
*/
|
||||||
|
payloadHash: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes signature = 8;
|
||||||
|
*/
|
||||||
|
signature: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string trace_id = 9;
|
||||||
|
*/
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message galaxy.gateway.v1.ExecuteCommandRequest.
|
||||||
|
* Use `create(ExecuteCommandRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ExecuteCommandRequestSchema: GenMessage<ExecuteCommandRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_galaxy_gateway_v1_edge_gateway, 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message galaxy.gateway.v1.ExecuteCommandResponse
|
||||||
|
*/
|
||||||
|
export type ExecuteCommandResponse = Message<"galaxy.gateway.v1.ExecuteCommandResponse"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string protocol_version = 1;
|
||||||
|
*/
|
||||||
|
protocolVersion: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string request_id = 2;
|
||||||
|
*/
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 timestamp_ms = 3;
|
||||||
|
*/
|
||||||
|
timestampMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string result_code = 4;
|
||||||
|
*/
|
||||||
|
resultCode: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_bytes = 5;
|
||||||
|
*/
|
||||||
|
payloadBytes: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_hash = 6;
|
||||||
|
*/
|
||||||
|
payloadHash: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes signature = 7;
|
||||||
|
*/
|
||||||
|
signature: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message galaxy.gateway.v1.ExecuteCommandResponse.
|
||||||
|
* Use `create(ExecuteCommandResponseSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const ExecuteCommandResponseSchema: GenMessage<ExecuteCommandResponse> = /*@__PURE__*/
|
||||||
|
messageDesc(file_galaxy_gateway_v1_edge_gateway, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message galaxy.gateway.v1.SubscribeEventsRequest
|
||||||
|
*/
|
||||||
|
export type SubscribeEventsRequest = Message<"galaxy.gateway.v1.SubscribeEventsRequest"> & {
|
||||||
|
/**
|
||||||
|
* protocol_version identifies the request envelope version. The gateway
|
||||||
|
* accepts only the literal "v1" after required-field validation succeeds.
|
||||||
|
*
|
||||||
|
* @generated from field: string protocol_version = 1;
|
||||||
|
*/
|
||||||
|
protocolVersion: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string device_session_id = 2;
|
||||||
|
*/
|
||||||
|
deviceSessionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string message_type = 3;
|
||||||
|
*/
|
||||||
|
messageType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 timestamp_ms = 4;
|
||||||
|
*/
|
||||||
|
timestampMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string request_id = 5;
|
||||||
|
*/
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* payload_hash is the raw 32-byte SHA-256 digest of payload_bytes. Empty
|
||||||
|
* payloads must use the SHA-256 digest of the empty byte slice.
|
||||||
|
*
|
||||||
|
* @generated from field: bytes payload_hash = 6;
|
||||||
|
*/
|
||||||
|
payloadHash: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes signature = 7;
|
||||||
|
*/
|
||||||
|
signature: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_bytes = 8;
|
||||||
|
*/
|
||||||
|
payloadBytes: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string trace_id = 9;
|
||||||
|
*/
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message galaxy.gateway.v1.SubscribeEventsRequest.
|
||||||
|
* Use `create(SubscribeEventsRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const SubscribeEventsRequestSchema: GenMessage<SubscribeEventsRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_galaxy_gateway_v1_edge_gateway, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message galaxy.gateway.v1.GatewayEvent
|
||||||
|
*/
|
||||||
|
export type GatewayEvent = Message<"galaxy.gateway.v1.GatewayEvent"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string event_type = 1;
|
||||||
|
*/
|
||||||
|
eventType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string event_id = 2;
|
||||||
|
*/
|
||||||
|
eventId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: int64 timestamp_ms = 3;
|
||||||
|
*/
|
||||||
|
timestampMs: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_bytes = 4;
|
||||||
|
*/
|
||||||
|
payloadBytes: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes payload_hash = 5;
|
||||||
|
*/
|
||||||
|
payloadHash: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bytes signature = 6;
|
||||||
|
*/
|
||||||
|
signature: Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string request_id = 7;
|
||||||
|
*/
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string trace_id = 8;
|
||||||
|
*/
|
||||||
|
traceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message galaxy.gateway.v1.GatewayEvent.
|
||||||
|
* Use `create(GatewayEventSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GatewayEventSchema: GenMessage<GatewayEvent> = /*@__PURE__*/
|
||||||
|
messageDesc(file_galaxy_gateway_v1_edge_gateway, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from service galaxy.gateway.v1.EdgeGateway
|
||||||
|
*/
|
||||||
|
export const EdgeGateway: GenService<{
|
||||||
|
/**
|
||||||
|
* @generated from rpc galaxy.gateway.v1.EdgeGateway.ExecuteCommand
|
||||||
|
*/
|
||||||
|
executeCommand: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof ExecuteCommandRequestSchema;
|
||||||
|
output: typeof ExecuteCommandResponseSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc galaxy.gateway.v1.EdgeGateway.SubscribeEvents
|
||||||
|
*/
|
||||||
|
subscribeEvents: {
|
||||||
|
methodKind: "server_streaming";
|
||||||
|
input: typeof SubscribeEventsRequestSchema;
|
||||||
|
output: typeof GatewayEventSchema;
|
||||||
|
},
|
||||||
|
}> = /*@__PURE__*/
|
||||||
|
serviceDesc(file_galaxy_gateway_v1_edge_gateway, 0);
|
||||||
|
|
||||||
Binary file not shown.
@@ -0,0 +1,559 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
//
|
||||||
|
// This file has been modified for use by the TinyGo compiler.
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
// Map multiple JavaScript environments to a single common API,
|
||||||
|
// preferring web standards over Node.js API.
|
||||||
|
//
|
||||||
|
// Environments considered:
|
||||||
|
// - Browsers
|
||||||
|
// - Node.js
|
||||||
|
// - Electron
|
||||||
|
// - Parcel
|
||||||
|
|
||||||
|
if (typeof global !== "undefined") {
|
||||||
|
// global already exists
|
||||||
|
} else if (typeof window !== "undefined") {
|
||||||
|
window.global = window;
|
||||||
|
} else if (typeof self !== "undefined") {
|
||||||
|
self.global = self;
|
||||||
|
} else {
|
||||||
|
throw new Error("cannot export Go (neither global, window nor self is defined)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.require && typeof require !== "undefined") {
|
||||||
|
global.require = require;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.fs && global.require) {
|
||||||
|
global.fs = require("node:fs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!global.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
global.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substr(0, nl));
|
||||||
|
outputBuf = outputBuf.substr(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.process) {
|
||||||
|
global.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.crypto) {
|
||||||
|
const nodeCrypto = require("node:crypto");
|
||||||
|
global.crypto = {
|
||||||
|
getRandomValues(b) {
|
||||||
|
nodeCrypto.randomFillSync(b);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.performance) {
|
||||||
|
global.performance = {
|
||||||
|
now() {
|
||||||
|
const [sec, nsec] = process.hrtime();
|
||||||
|
return sec * 1000 + nsec / 1000000;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.TextEncoder) {
|
||||||
|
global.TextEncoder = require("node:util").TextEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.TextDecoder) {
|
||||||
|
global.TextDecoder = require("node:util").TextDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of polyfills for common API.
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let reinterpretBuf = new DataView(new ArrayBuffer(8));
|
||||||
|
var logLine = [];
|
||||||
|
const wasmExit = {}; // thrown to exit via proc_exit (not an error)
|
||||||
|
|
||||||
|
global.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this._callbackTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const mem = () => {
|
||||||
|
// The buffer may change when requesting more memory.
|
||||||
|
return new DataView(this._inst.exports.memory.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unboxValue = (v_ref) => {
|
||||||
|
reinterpretBuf.setBigInt64(0, v_ref, true);
|
||||||
|
const f = reinterpretBuf.getFloat64(0, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = v_ref & 0xffffffffn;
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
let v_ref = mem().getBigUint64(addr, true);
|
||||||
|
return unboxValue(v_ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxValue = (v) => {
|
||||||
|
const nanHead = 0x7FF80000n;
|
||||||
|
|
||||||
|
if (typeof v === "number") {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
return nanHead << 32n;
|
||||||
|
}
|
||||||
|
if (v === 0) {
|
||||||
|
return (nanHead << 32n) | 1n;
|
||||||
|
}
|
||||||
|
reinterpretBuf.setFloat64(0, v, true);
|
||||||
|
return reinterpretBuf.getBigInt64(0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (v) {
|
||||||
|
case undefined:
|
||||||
|
return 0n;
|
||||||
|
case null:
|
||||||
|
return (nanHead << 32n) | 2n;
|
||||||
|
case true:
|
||||||
|
return (nanHead << 32n) | 3n;
|
||||||
|
case false:
|
||||||
|
return (nanHead << 32n) | 4n;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = BigInt(this._values.length);
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 1n;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2n;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3n;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return id | ((nanHead | typeFlag) << 32n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
let v_ref = boxValue(v);
|
||||||
|
mem().setBigUint64(addr, v_ref, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (array, len, cap) => {
|
||||||
|
return new Uint8Array(this._inst.exports.memory.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (array, len, cap) => {
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (ptr, len) => {
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
const wasi_EBADF = 8;
|
||||||
|
const wasi_ENOSYS = 52;
|
||||||
|
this.importObject = {
|
||||||
|
wasi_snapshot_preview1: {
|
||||||
|
// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md
|
||||||
|
fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) {
|
||||||
|
let nwritten = 0;
|
||||||
|
if (fd == 1) {
|
||||||
|
for (let iovs_i=0; iovs_i<iovs_len;iovs_i++) {
|
||||||
|
let iov_ptr = iovs_ptr+iovs_i*8; // assuming wasm32
|
||||||
|
let ptr = mem().getUint32(iov_ptr + 0, true);
|
||||||
|
let len = mem().getUint32(iov_ptr + 4, true);
|
||||||
|
nwritten += len;
|
||||||
|
for (let i=0; i<len; i++) {
|
||||||
|
let c = mem().getUint8(ptr+i);
|
||||||
|
if (c == 13) { // CR
|
||||||
|
// ignore
|
||||||
|
} else if (c == 10) { // LF
|
||||||
|
// write line
|
||||||
|
let line = decoder.decode(new Uint8Array(logLine));
|
||||||
|
logLine = [];
|
||||||
|
console.log(line);
|
||||||
|
} else {
|
||||||
|
logLine.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('invalid file descriptor:', fd);
|
||||||
|
}
|
||||||
|
mem().setUint32(nwritten_ptr, nwritten, true);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
fd_read: () => wasi_ENOSYS,
|
||||||
|
fd_close: () => wasi_ENOSYS,
|
||||||
|
fd_fdstat_get: () => wasi_ENOSYS,
|
||||||
|
fd_prestat_get: () => wasi_EBADF, // wasi-libc relies on this errno value
|
||||||
|
fd_prestat_dir_name: () => wasi_ENOSYS,
|
||||||
|
fd_seek: () => wasi_ENOSYS,
|
||||||
|
path_open: () => wasi_ENOSYS,
|
||||||
|
proc_exit: (code) => {
|
||||||
|
this.exited = true;
|
||||||
|
this.exitCode = code;
|
||||||
|
this._resolveExitPromise();
|
||||||
|
throw wasmExit;
|
||||||
|
},
|
||||||
|
random_get: (bufPtr, bufLen) => {
|
||||||
|
crypto.getRandomValues(loadSlice(bufPtr, bufLen));
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// func ticks() int64
|
||||||
|
"runtime.ticks": () => {
|
||||||
|
return BigInt((timeOrigin + performance.now()) * 1e6);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func sleepTicks(timeout int64)
|
||||||
|
"runtime.sleepTicks": (timeout) => {
|
||||||
|
// Do not sleep, only reactivate scheduler after the given timeout.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.exited) return;
|
||||||
|
try {
|
||||||
|
this._inst.exports.go_scheduler();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== wasmExit) throw e;
|
||||||
|
}
|
||||||
|
}, Number(timeout)/1e6);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (v_ref) => {
|
||||||
|
// Note: TinyGo does not support finalizers so this is only called
|
||||||
|
// for one specific case, by js.go:jsString. and can/might leak memory.
|
||||||
|
const id = v_ref & 0xffffffffn;
|
||||||
|
if (this._goRefCounts?.[id] !== undefined) {
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("syscall/js.finalizeRef: unknown id", id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (value_ptr, value_len) => {
|
||||||
|
value_ptr >>>= 0;
|
||||||
|
const s = loadString(value_ptr, value_len);
|
||||||
|
return boxValue(s);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (v_ref, p_ptr, p_len) => {
|
||||||
|
let prop = loadString(p_ptr, p_len);
|
||||||
|
let v = unboxValue(v_ref);
|
||||||
|
let result = Reflect.get(v, prop);
|
||||||
|
return boxValue(result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => {
|
||||||
|
const v = unboxValue(v_ref);
|
||||||
|
const p = loadString(p_ptr, p_len);
|
||||||
|
const x = unboxValue(x_ref);
|
||||||
|
Reflect.set(v, p, x);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (v_ref, p_ptr, p_len) => {
|
||||||
|
const v = unboxValue(v_ref);
|
||||||
|
const p = loadString(p_ptr, p_len);
|
||||||
|
Reflect.deleteProperty(v, p);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (v_ref, i) => {
|
||||||
|
return boxValue(Reflect.get(unboxValue(v_ref), i));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (v_ref, i, x_ref) => {
|
||||||
|
Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => {
|
||||||
|
const v = unboxValue(v_ref);
|
||||||
|
const name = loadString(m_ptr, m_len);
|
||||||
|
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
|
||||||
|
try {
|
||||||
|
const m = Reflect.get(v, name);
|
||||||
|
storeValue(ret_addr, Reflect.apply(m, v, args));
|
||||||
|
mem().setUint8(ret_addr + 8, 1);
|
||||||
|
} catch (err) {
|
||||||
|
storeValue(ret_addr, err);
|
||||||
|
mem().setUint8(ret_addr + 8, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
|
||||||
|
try {
|
||||||
|
const v = unboxValue(v_ref);
|
||||||
|
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
|
||||||
|
storeValue(ret_addr, Reflect.apply(v, undefined, args));
|
||||||
|
mem().setUint8(ret_addr + 8, 1);
|
||||||
|
} catch (err) {
|
||||||
|
storeValue(ret_addr, err);
|
||||||
|
mem().setUint8(ret_addr + 8, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
|
||||||
|
const v = unboxValue(v_ref);
|
||||||
|
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
|
||||||
|
try {
|
||||||
|
storeValue(ret_addr, Reflect.construct(v, args));
|
||||||
|
mem().setUint8(ret_addr + 8, 1);
|
||||||
|
} catch (err) {
|
||||||
|
storeValue(ret_addr, err);
|
||||||
|
mem().setUint8(ret_addr+ 8, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (v_ref) => {
|
||||||
|
return unboxValue(v_ref).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (ret_addr, v_ref) => {
|
||||||
|
const s = String(unboxValue(v_ref));
|
||||||
|
const str = encoder.encode(s);
|
||||||
|
storeValue(ret_addr, str);
|
||||||
|
mem().setInt32(ret_addr + 8, str.length, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => {
|
||||||
|
const str = unboxValue(v_ref);
|
||||||
|
loadSlice(slice_ptr, slice_len, slice_cap).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (v_ref, t_ref) => {
|
||||||
|
return unboxValue(v_ref) instanceof unboxValue(t_ref);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => {
|
||||||
|
let num_bytes_copied_addr = ret_addr;
|
||||||
|
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
|
||||||
|
|
||||||
|
const dst = loadSlice(dest_addr, dest_len);
|
||||||
|
const src = unboxValue(src_ref);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
|
||||||
|
mem().setUint8(returned_status_addr, 1); // Return "ok" status
|
||||||
|
},
|
||||||
|
|
||||||
|
// copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
// Originally copied from upstream Go project, then modified:
|
||||||
|
// https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416
|
||||||
|
"syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => {
|
||||||
|
let num_bytes_copied_addr = ret_addr;
|
||||||
|
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
|
||||||
|
|
||||||
|
const dst = unboxValue(dst_ref);
|
||||||
|
const src = loadSlice(src_addr, src_len);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
|
||||||
|
mem().setUint8(returned_status_addr, 1); // Return "ok" status
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Go 1.20 uses 'env'. Go 1.21 uses 'gojs'.
|
||||||
|
// For compatibility, we use both as long as Go 1.20 is supported.
|
||||||
|
this.importObject.env = this.importObject.gojs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
this._inst = instance;
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
global,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map(); // mapping from JS values to reference ids
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
this.exitCode = 0;
|
||||||
|
|
||||||
|
if (this._inst.exports._start) {
|
||||||
|
let exitPromise = new Promise((resolve, reject) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run program, but catch the wasmExit exception that's thrown
|
||||||
|
// to return back here.
|
||||||
|
try {
|
||||||
|
this._inst.exports._start();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== wasmExit) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await exitPromise;
|
||||||
|
return this.exitCode;
|
||||||
|
} else {
|
||||||
|
this._inst.exports._initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._inst.exports.resume();
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== wasmExit) throw e;
|
||||||
|
}
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
global.require &&
|
||||||
|
global.require.main === module &&
|
||||||
|
global.process &&
|
||||||
|
global.process.versions &&
|
||||||
|
!global.process.versions.electron
|
||||||
|
) {
|
||||||
|
if (process.argv.length != 3) {
|
||||||
|
console.error("usage: go_js_wasm_exec [wasm binary] [arguments]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = new Go();
|
||||||
|
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => {
|
||||||
|
let exitCode = await go.run(result.instance);
|
||||||
|
process.exit(exitCode);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Verifies the orchestration order in `GalaxyClient.executeCommand`:
|
||||||
|
//
|
||||||
|
// core.signRequest(fields)
|
||||||
|
// → signer(canonicalBytes)
|
||||||
|
// → edge.executeCommand({ envelope, signature })
|
||||||
|
// → core.verifyPayloadHash(response payload, hash)
|
||||||
|
// → core.verifyResponse(envelope, signature)
|
||||||
|
// → return response.payloadBytes
|
||||||
|
//
|
||||||
|
// Tests use a hand-rolled mock `Core` and a Connect router transport
|
||||||
|
// from `@connectrpc/connect`, which lets us assert what the gateway
|
||||||
|
// would have received without standing up a real Connect server.
|
||||||
|
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
|
import { createClient, createRouterTransport } from "@connectrpc/connect";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { GalaxyClient } from "../src/api/galaxy-client";
|
||||||
|
import {
|
||||||
|
EdgeGateway,
|
||||||
|
ExecuteCommandResponseSchema,
|
||||||
|
type ExecuteCommandRequest,
|
||||||
|
} from "../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
|
import type {
|
||||||
|
Core,
|
||||||
|
RequestSigningFields,
|
||||||
|
ResponseSigningFields,
|
||||||
|
} from "../src/platform/core/index";
|
||||||
|
|
||||||
|
const FIXED_REQUEST_ID = "req-test-1";
|
||||||
|
const FIXED_TIMESTAMP = 1_700_000_000_000n;
|
||||||
|
|
||||||
|
describe("GalaxyClient.executeCommand", () => {
|
||||||
|
test("orchestrates sign → fetch → verify and returns the payload", async () => {
|
||||||
|
const canonicalBytes = new Uint8Array([0xca, 0xfe]);
|
||||||
|
const signature = new Uint8Array(64).fill(0x55);
|
||||||
|
const responsePayload = new TextEncoder().encode("hello-from-server");
|
||||||
|
const responseHash = new Uint8Array(32).fill(0x77);
|
||||||
|
const responseSignature = new Uint8Array(64).fill(0x99);
|
||||||
|
const responsePublicKey = new Uint8Array(32).fill(0x11);
|
||||||
|
|
||||||
|
const core = mockCore({
|
||||||
|
signRequestImpl: () => canonicalBytes,
|
||||||
|
verifyResponseImpl: () => true,
|
||||||
|
verifyPayloadHashImpl: () => true,
|
||||||
|
});
|
||||||
|
const signer = vi.fn(async () => signature);
|
||||||
|
const sha256 = vi.fn(async () => new Uint8Array(32).fill(0x33));
|
||||||
|
let captured: ExecuteCommandRequest | undefined;
|
||||||
|
const transport = createRouterTransport(({ service }) => {
|
||||||
|
service(EdgeGateway, {
|
||||||
|
executeCommand(req) {
|
||||||
|
captured = req;
|
||||||
|
return create(ExecuteCommandResponseSchema, {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: FIXED_REQUEST_ID,
|
||||||
|
timestampMs: FIXED_TIMESTAMP,
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: responsePayload,
|
||||||
|
payloadHash: responseHash,
|
||||||
|
signature: responseSignature,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
subscribeEvents() {
|
||||||
|
throw new Error("not used in this test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const edge = createClient(EdgeGateway, transport);
|
||||||
|
|
||||||
|
const client = new GalaxyClient({
|
||||||
|
core,
|
||||||
|
edge,
|
||||||
|
signer,
|
||||||
|
sha256,
|
||||||
|
deviceSessionId: "device-session-1",
|
||||||
|
gatewayResponsePublicKey: responsePublicKey,
|
||||||
|
clock: () => FIXED_TIMESTAMP,
|
||||||
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await client.executeCommand(
|
||||||
|
"user.account.get",
|
||||||
|
new TextEncoder().encode("client-payload"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Array.from(out)).toEqual(Array.from(responsePayload));
|
||||||
|
expect(signer).toHaveBeenCalledWith(canonicalBytes);
|
||||||
|
expect(sha256).toHaveBeenCalledTimes(1);
|
||||||
|
expect(core.signRequest).toHaveBeenCalledTimes(1);
|
||||||
|
const verifyHashCall = vi.mocked(core.verifyPayloadHash).mock.calls[0]!;
|
||||||
|
expect(Array.from(verifyHashCall[0])).toEqual(Array.from(responsePayload));
|
||||||
|
expect(Array.from(verifyHashCall[1])).toEqual(Array.from(responseHash));
|
||||||
|
const verifyRespCall = vi.mocked(core.verifyResponse).mock.calls[0]!;
|
||||||
|
expect(Array.from(verifyRespCall[0])).toEqual(Array.from(responsePublicKey));
|
||||||
|
expect(Array.from(verifyRespCall[1])).toEqual(Array.from(responseSignature));
|
||||||
|
expect(verifyRespCall[2]).toMatchObject({
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: FIXED_REQUEST_ID,
|
||||||
|
resultCode: "ok",
|
||||||
|
});
|
||||||
|
expect(captured?.deviceSessionId).toBe("device-session-1");
|
||||||
|
expect(captured?.messageType).toBe("user.account.get");
|
||||||
|
expect(captured?.requestId).toBe(FIXED_REQUEST_ID);
|
||||||
|
expect(Array.from(captured?.signature ?? [])).toEqual(
|
||||||
|
Array.from(signature),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the response signature fails verification", async () => {
|
||||||
|
const core = mockCore({
|
||||||
|
signRequestImpl: () => new Uint8Array(),
|
||||||
|
verifyResponseImpl: () => false,
|
||||||
|
verifyPayloadHashImpl: () => true,
|
||||||
|
});
|
||||||
|
const transport = createRouterTransport(({ service }) => {
|
||||||
|
service(EdgeGateway, {
|
||||||
|
executeCommand: () =>
|
||||||
|
create(ExecuteCommandResponseSchema, {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: FIXED_REQUEST_ID,
|
||||||
|
timestampMs: FIXED_TIMESTAMP,
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: new Uint8Array(),
|
||||||
|
payloadHash: new Uint8Array(32),
|
||||||
|
signature: new Uint8Array(64),
|
||||||
|
}),
|
||||||
|
subscribeEvents() {
|
||||||
|
throw new Error("not used in this test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const client = new GalaxyClient({
|
||||||
|
core,
|
||||||
|
edge: createClient(EdgeGateway, transport),
|
||||||
|
signer: async () => new Uint8Array(64),
|
||||||
|
sha256: async () => new Uint8Array(32),
|
||||||
|
deviceSessionId: "device-session-1",
|
||||||
|
gatewayResponsePublicKey: new Uint8Array(32),
|
||||||
|
clock: () => FIXED_TIMESTAMP,
|
||||||
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.executeCommand("user.account.get", new Uint8Array()),
|
||||||
|
).rejects.toThrow(/invalid response signature/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the response payload_hash does not match", async () => {
|
||||||
|
const core = mockCore({
|
||||||
|
signRequestImpl: () => new Uint8Array(),
|
||||||
|
verifyResponseImpl: () => true,
|
||||||
|
verifyPayloadHashImpl: () => false,
|
||||||
|
});
|
||||||
|
const transport = createRouterTransport(({ service }) => {
|
||||||
|
service(EdgeGateway, {
|
||||||
|
executeCommand: () =>
|
||||||
|
create(ExecuteCommandResponseSchema, {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: FIXED_REQUEST_ID,
|
||||||
|
timestampMs: FIXED_TIMESTAMP,
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: new Uint8Array(),
|
||||||
|
payloadHash: new Uint8Array(32),
|
||||||
|
signature: new Uint8Array(64),
|
||||||
|
}),
|
||||||
|
subscribeEvents() {
|
||||||
|
throw new Error("not used in this test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const client = new GalaxyClient({
|
||||||
|
core,
|
||||||
|
edge: createClient(EdgeGateway, transport),
|
||||||
|
signer: async () => new Uint8Array(64),
|
||||||
|
sha256: async () => new Uint8Array(32),
|
||||||
|
deviceSessionId: "device-session-1",
|
||||||
|
gatewayResponsePublicKey: new Uint8Array(32),
|
||||||
|
clock: () => FIXED_TIMESTAMP,
|
||||||
|
requestIdFactory: () => FIXED_REQUEST_ID,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.executeCommand("user.account.get", new Uint8Array()),
|
||||||
|
).rejects.toThrow(/payload_hash mismatch/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MockCoreOptions {
|
||||||
|
signRequestImpl: (fields: RequestSigningFields) => Uint8Array;
|
||||||
|
verifyResponseImpl: (
|
||||||
|
publicKey: Uint8Array,
|
||||||
|
signature: Uint8Array,
|
||||||
|
fields: ResponseSigningFields,
|
||||||
|
) => boolean;
|
||||||
|
verifyPayloadHashImpl: (
|
||||||
|
payload: Uint8Array,
|
||||||
|
hash: Uint8Array,
|
||||||
|
) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockCore(opts: MockCoreOptions): Core & {
|
||||||
|
signRequest: ReturnType<typeof vi.fn>;
|
||||||
|
verifyResponse: ReturnType<typeof vi.fn>;
|
||||||
|
verifyPayloadHash: ReturnType<typeof vi.fn>;
|
||||||
|
verifyEvent: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
signRequest: vi.fn(opts.signRequestImpl),
|
||||||
|
verifyResponse: vi.fn(opts.verifyResponseImpl),
|
||||||
|
verifyEvent: vi.fn(() => true),
|
||||||
|
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Boots the TinyGo `core.wasm` module under Vitest/JSDOM by reading
|
||||||
|
// the artefact and the matching wasm_exec.js shim from disk, evaluating
|
||||||
|
// the shim into the test's global scope, then instantiating WebAssembly
|
||||||
|
// against `(new Go()).importObject`. Returns a `Core` ready to use in
|
||||||
|
// tests; subsequent calls return the cached instance so each Vitest
|
||||||
|
// file pays the boot cost once.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { runInThisContext } from "node:vm";
|
||||||
|
import { adaptBridge, requireBridge } from "../src/platform/core/wasm";
|
||||||
|
import type { Core } from "../src/platform/core/index";
|
||||||
|
|
||||||
|
// Vitest is launched from ui/frontend, so the static artefacts produced
|
||||||
|
// by `make wasm` sit at <cwd>/static/. Resolving via process.cwd avoids
|
||||||
|
// the JSDOM-specific "URL must be of scheme file" error that
|
||||||
|
// import.meta.url triggers under the jsdom test environment.
|
||||||
|
const STATIC_DIR = resolve(process.cwd(), "static") + "/";
|
||||||
|
|
||||||
|
let cached: Promise<Core> | undefined;
|
||||||
|
|
||||||
|
export function loadWasmCoreForTest(): Promise<Core> {
|
||||||
|
if (!cached) {
|
||||||
|
cached = boot();
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<Core> {
|
||||||
|
if (typeof globalThis.Go === "undefined") {
|
||||||
|
const shim = readFileSync(`${STATIC_DIR}wasm_exec.js`, "utf8");
|
||||||
|
runInThisContext(shim);
|
||||||
|
}
|
||||||
|
const Go = globalThis.Go;
|
||||||
|
if (!Go) {
|
||||||
|
throw new Error("setup-wasm: Go runtime missing after wasm_exec.js eval");
|
||||||
|
}
|
||||||
|
const go = new Go();
|
||||||
|
const wasm = readFileSync(`${STATIC_DIR}core.wasm`);
|
||||||
|
const { instance } = await WebAssembly.instantiate(
|
||||||
|
wasm,
|
||||||
|
go.importObject,
|
||||||
|
);
|
||||||
|
void go.run(instance);
|
||||||
|
// `go.run` is async but the Go side registers `globalThis.galaxyCore`
|
||||||
|
// and then blocks on `select {}`; the registration happens
|
||||||
|
// synchronously before the first await, so by the time the next
|
||||||
|
// microtask runs the bridge is already available.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
return adaptBridge(requireBridge());
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Asserts that the WASM-compiled `ui/core` produces canonical bytes
|
||||||
|
// byte-for-byte identical to the gateway-side fixtures committed in
|
||||||
|
// `ui/core/canon/testdata/`. The fixtures themselves are exercised by
|
||||||
|
// the Go-side parity test in `gateway/authn/parity_with_ui_core_test.go`,
|
||||||
|
// so passing both proves Go-Go parity AND Go-TS parity through WASM.
|
||||||
|
//
|
||||||
|
// The signature parity check additionally proves that a signature
|
||||||
|
// produced by Node's `ed25519` over the WASM-built canonical bytes is
|
||||||
|
// accepted by the gateway's `VerifyResponseSignature` /
|
||||||
|
// `VerifyEventSignature` (re-checked here via Core.verifyResponse /
|
||||||
|
// Core.verifyEvent against the same fixture key).
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPrivateKey,
|
||||||
|
createPublicKey,
|
||||||
|
sign as cryptoSign,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import type { Core } from "../src/platform/core/index";
|
||||||
|
import { loadWasmCoreForTest } from "./setup-wasm";
|
||||||
|
|
||||||
|
const TESTDATA_DIR = resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"..",
|
||||||
|
"core",
|
||||||
|
"canon",
|
||||||
|
"testdata",
|
||||||
|
) + "/";
|
||||||
|
|
||||||
|
interface RequestFixture {
|
||||||
|
protocol_version: string;
|
||||||
|
device_session_id: string;
|
||||||
|
message_type: string;
|
||||||
|
timestamp_ms: number;
|
||||||
|
request_id: string;
|
||||||
|
payload: string;
|
||||||
|
payload_hash_hex: string;
|
||||||
|
expected_canonical_bytes_hex: string;
|
||||||
|
private_key_seed_hex: string;
|
||||||
|
public_key_base64: string;
|
||||||
|
expected_signature_hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseFixture {
|
||||||
|
protocol_version: string;
|
||||||
|
request_id: string;
|
||||||
|
timestamp_ms: number;
|
||||||
|
result_code: string;
|
||||||
|
payload: string;
|
||||||
|
payload_hash_hex: string;
|
||||||
|
expected_canonical_bytes_hex: string;
|
||||||
|
private_key_seed_hex: string;
|
||||||
|
public_key_base64: string;
|
||||||
|
expected_signature_hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventFixture {
|
||||||
|
event_type: string;
|
||||||
|
event_id: string;
|
||||||
|
timestamp_ms: number;
|
||||||
|
request_id: string;
|
||||||
|
trace_id: string;
|
||||||
|
payload: string;
|
||||||
|
payload_hash_hex: string;
|
||||||
|
expected_canonical_bytes_hex: string;
|
||||||
|
private_key_seed_hex: string;
|
||||||
|
public_key_base64: string;
|
||||||
|
expected_signature_hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core: Core;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
core = await loadWasmCoreForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WasmCore canon parity with gateway fixtures", () => {
|
||||||
|
test.each([
|
||||||
|
"request_user_account_get.json",
|
||||||
|
"request_user_games_command.json",
|
||||||
|
"request_lobby_my_games_list.json",
|
||||||
|
])("%s — canonical bytes byte-for-byte equal", (name) => {
|
||||||
|
const fixture = readJson<RequestFixture>(name);
|
||||||
|
const canonical = core.signRequest({
|
||||||
|
protocolVersion: fixture.protocol_version,
|
||||||
|
deviceSessionId: fixture.device_session_id,
|
||||||
|
messageType: fixture.message_type,
|
||||||
|
timestampMs: BigInt(fixture.timestamp_ms),
|
||||||
|
requestId: fixture.request_id,
|
||||||
|
payloadHash: hexToBytes(fixture.payload_hash_hex),
|
||||||
|
});
|
||||||
|
expect(bytesToHex(canonical)).toBe(fixture.expected_canonical_bytes_hex);
|
||||||
|
|
||||||
|
const signature = signEd25519(
|
||||||
|
hexToBytes(fixture.private_key_seed_hex),
|
||||||
|
canonical,
|
||||||
|
);
|
||||||
|
expect(bytesToHex(signature)).toBe(fixture.expected_signature_hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("response_ok.json — verifyResponse accepts canonical signature", () => {
|
||||||
|
const fixture = readJson<ResponseFixture>("response_ok.json");
|
||||||
|
const publicKey = base64ToBytes(fixture.public_key_base64);
|
||||||
|
const signature = hexToBytes(fixture.expected_signature_hex);
|
||||||
|
const fields = {
|
||||||
|
protocolVersion: fixture.protocol_version,
|
||||||
|
requestId: fixture.request_id,
|
||||||
|
timestampMs: BigInt(fixture.timestamp_ms),
|
||||||
|
resultCode: fixture.result_code,
|
||||||
|
payloadHash: hexToBytes(fixture.payload_hash_hex),
|
||||||
|
};
|
||||||
|
expect(core.verifyResponse(publicKey, signature, fields)).toBe(true);
|
||||||
|
|
||||||
|
const tampered = new Uint8Array(signature);
|
||||||
|
tampered[0] ^= 0xff;
|
||||||
|
expect(core.verifyResponse(publicKey, tampered, fields)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("event_gateway_server_time.json — verifyEvent accepts canonical signature", () => {
|
||||||
|
const fixture = readJson<EventFixture>("event_gateway_server_time.json");
|
||||||
|
const publicKey = base64ToBytes(fixture.public_key_base64);
|
||||||
|
const signature = hexToBytes(fixture.expected_signature_hex);
|
||||||
|
const fields = {
|
||||||
|
eventType: fixture.event_type,
|
||||||
|
eventId: fixture.event_id,
|
||||||
|
timestampMs: BigInt(fixture.timestamp_ms),
|
||||||
|
requestId: fixture.request_id,
|
||||||
|
traceId: fixture.trace_id,
|
||||||
|
payloadHash: hexToBytes(fixture.payload_hash_hex),
|
||||||
|
};
|
||||||
|
expect(core.verifyEvent(publicKey, signature, fields)).toBe(true);
|
||||||
|
|
||||||
|
const tampered = new Uint8Array(signature);
|
||||||
|
tampered[0] ^= 0xff;
|
||||||
|
expect(core.verifyEvent(publicKey, tampered, fields)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readJson<T>(name: string): T {
|
||||||
|
return JSON.parse(readFileSync(`${TESTDATA_DIR}${name}`, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(value: string): Uint8Array {
|
||||||
|
const length = value.length / 2;
|
||||||
|
const out = new Uint8Array(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out[i] = parseInt(value.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(value: Uint8Array): string {
|
||||||
|
return Array.from(value, (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node's `ed25519` accepts a raw 32-byte seed packaged as a PKCS#8 DER
|
||||||
|
// blob; this helper synthesises that blob so the test fixture seed
|
||||||
|
// matches the Go-side `ed25519.NewKeyFromSeed` shape used by the
|
||||||
|
// committed canon golden values.
|
||||||
|
function signEd25519(seed: Uint8Array, message: Uint8Array): Uint8Array {
|
||||||
|
const pkcs8 = pkcs8FromEd25519Seed(seed);
|
||||||
|
const key = createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
|
||||||
|
const signature = cryptoSign(null, message, key);
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkcs8FromEd25519Seed(seed: Uint8Array): Buffer {
|
||||||
|
if (seed.length !== 32) {
|
||||||
|
throw new Error(`ed25519 seed must be 32 bytes, got ${seed.length}`);
|
||||||
|
}
|
||||||
|
// PKCS#8 wrapping for an Ed25519 raw seed:
|
||||||
|
// SEQUENCE {
|
||||||
|
// INTEGER 0,
|
||||||
|
// SEQUENCE { OID 1.3.101.112 (Ed25519) },
|
||||||
|
// OCTET STRING { OCTET STRING { seed } }
|
||||||
|
// }
|
||||||
|
const prefix = Buffer.from([
|
||||||
|
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70,
|
||||||
|
0x04, 0x22, 0x04, 0x20,
|
||||||
|
]);
|
||||||
|
return Buffer.concat([prefix, Buffer.from(seed)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `createPublicKey` is imported only so future tests can derive the
|
||||||
|
// public key from the same seed without going through the WASM bridge.
|
||||||
|
// Suppress the unused-import warning by referencing the binding here.
|
||||||
|
void createPublicKey;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import type { Core } from "../src/platform/core/index";
|
||||||
|
import { loadWasmCoreForTest } from "./setup-wasm";
|
||||||
|
|
||||||
|
let core: Core;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
core = await loadWasmCoreForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WasmCore.signRequest", () => {
|
||||||
|
test("returns canonical bytes that lead with the v1 domain marker", () => {
|
||||||
|
const fields = {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
deviceSessionId: "device-session-1",
|
||||||
|
messageType: "user.account.get",
|
||||||
|
timestampMs: 1700000000000n,
|
||||||
|
requestId: "req-1",
|
||||||
|
payloadHash: sha256("payload"),
|
||||||
|
};
|
||||||
|
const canonical = core.signRequest(fields);
|
||||||
|
const marker = "galaxy-request-v1";
|
||||||
|
expect(canonical.length).toBeGreaterThan(marker.length);
|
||||||
|
expect(new TextDecoder().decode(canonical.slice(1, 1 + marker.length))).toBe(
|
||||||
|
marker,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WasmCore.verifyResponse", () => {
|
||||||
|
test("rejects a flipped-bit signature", () => {
|
||||||
|
const publicKey = new Uint8Array(32);
|
||||||
|
const signature = new Uint8Array(64);
|
||||||
|
const fields = {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: "req-1",
|
||||||
|
timestampMs: 1700000000000n,
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadHash: sha256("payload"),
|
||||||
|
};
|
||||||
|
expect(core.verifyResponse(publicKey, signature, fields)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for malformed key/signature lengths", () => {
|
||||||
|
const fields = {
|
||||||
|
protocolVersion: "v1",
|
||||||
|
requestId: "req-1",
|
||||||
|
timestampMs: 1700000000000n,
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadHash: sha256("payload"),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
core.verifyResponse(new Uint8Array(8), new Uint8Array(64), fields),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WasmCore.verifyEvent", () => {
|
||||||
|
test("rejects an empty signature", () => {
|
||||||
|
const fields = {
|
||||||
|
eventType: "gateway.server_time",
|
||||||
|
eventId: "evt-1",
|
||||||
|
timestampMs: 1700000000000n,
|
||||||
|
requestId: "req-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
payloadHash: sha256("event"),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
core.verifyEvent(new Uint8Array(32), new Uint8Array(0), fields),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WasmCore.verifyPayloadHash", () => {
|
||||||
|
test("accepts the SHA-256 of the supplied payload", () => {
|
||||||
|
const payload = new TextEncoder().encode("hello");
|
||||||
|
expect(core.verifyPayloadHash(payload, sha256("hello"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a digest of unrelated bytes", () => {
|
||||||
|
const payload = new TextEncoder().encode("hello");
|
||||||
|
expect(core.verifyPayloadHash(payload, sha256("world"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a payload_hash whose length is not 32", () => {
|
||||||
|
const payload = new TextEncoder().encode("hello");
|
||||||
|
expect(core.verifyPayloadHash(payload, new Uint8Array(16))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sha256(value: string): Uint8Array {
|
||||||
|
return new Uint8Array(createHash("sha256").update(value).digest());
|
||||||
|
}
|
||||||
Generated
+85
@@ -8,6 +8,18 @@ importers:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@bufbuild/protobuf':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0
|
||||||
|
'@bufbuild/protoc-gen-es':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@bufbuild/protobuf@2.12.0)
|
||||||
|
'@connectrpc/connect':
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1(@bufbuild/protobuf@2.12.0)
|
||||||
|
'@connectrpc/connect-web':
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.59.1
|
specifier: ^1.59.1
|
||||||
version: 1.59.1
|
version: 1.59.1
|
||||||
@@ -74,6 +86,33 @@ packages:
|
|||||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.12.0':
|
||||||
|
resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==}
|
||||||
|
|
||||||
|
'@bufbuild/protoc-gen-es@2.12.0':
|
||||||
|
resolution: {integrity: sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@bufbuild/protobuf': 2.12.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@bufbuild/protobuf':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/protoplugin@2.12.0':
|
||||||
|
resolution: {integrity: sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==}
|
||||||
|
|
||||||
|
'@connectrpc/connect-web@2.1.1':
|
||||||
|
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@bufbuild/protobuf': ^2.7.0
|
||||||
|
'@connectrpc/connect': 2.1.1
|
||||||
|
|
||||||
|
'@connectrpc/connect@2.1.1':
|
||||||
|
resolution: {integrity: sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@bufbuild/protobuf': ^2.7.0
|
||||||
|
|
||||||
'@csstools/color-helpers@5.1.0':
|
'@csstools/color-helpers@5.1.0':
|
||||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -329,6 +368,11 @@ packages:
|
|||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
|
'@typescript/vfs@1.6.4':
|
||||||
|
resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
|
||||||
'@vitest/expect@4.1.5':
|
'@vitest/expect@4.1.5':
|
||||||
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
|
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
|
||||||
|
|
||||||
@@ -874,6 +918,11 @@ packages:
|
|||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
typescript@5.4.5:
|
||||||
|
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1044,6 +1093,33 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.29.2': {}
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.12.0': {}
|
||||||
|
|
||||||
|
'@bufbuild/protoc-gen-es@2.12.0(@bufbuild/protobuf@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protoplugin': 2.12.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@bufbuild/protobuf': 2.12.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@bufbuild/protoplugin@2.12.0':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protobuf': 2.12.0
|
||||||
|
'@typescript/vfs': 1.6.4(typescript@5.4.5)
|
||||||
|
typescript: 5.4.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protobuf': 2.12.0
|
||||||
|
'@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.12.0)
|
||||||
|
|
||||||
|
'@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0)':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protobuf': 2.12.0
|
||||||
|
|
||||||
'@csstools/color-helpers@5.1.0': {}
|
'@csstools/color-helpers@5.1.0': {}
|
||||||
|
|
||||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||||
@@ -1261,6 +1337,13 @@ snapshots:
|
|||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
|
'@typescript/vfs@1.6.4(typescript@5.4.5)':
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
typescript: 5.4.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@vitest/expect@4.1.5':
|
'@vitest/expect@4.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -1765,6 +1848,8 @@ snapshots:
|
|||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
typescript@5.4.5: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|||||||
@@ -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