Merge PR #35: jules/audit-error-handling-10513175768831762746

This commit is contained in:
copilot-swe-agent[bot] 2026-02-02 06:34:01 +00:00
commit 974e76c37a
6 changed files with 330 additions and 111 deletions

View file

@ -1,4 +1,3 @@
version: "2"
run: run:
timeout: 5m timeout: 5m
linters: linters:

69
AUDIT-ERROR-HANDLING.md Normal file
View file

@ -0,0 +1,69 @@
# Audit: Error Handling & Logging
This audit reviews the error handling and logging practices of the Poindexter Go library, focusing on the core `kdtree.go` implementation and the `wasm/main.go` wrapper.
## Error Handling
### Exception Handling
- [x] **Are exceptions caught appropriately?**
- **Finding:** Yes. As a Go library, it doesn't use exceptions but instead returns `error` types. The code diligently checks for and propagates errors. For instance, `NewKDTree` returns an error for invalid input, and callers are expected to handle it.
- [x] **Generic catches hiding bugs?**
- **Finding:** No. The library defines specific, exported error variables (e.g., `ErrEmptyPoints`, `ErrDimMismatch`) in `kdtree.go`. This allows consumers to programmatically check for specific error conditions using `errors.Is`, which is a best practice.
- [x] **Error information leakage?**
- **Finding:** Previously, the WASM wrapper at `wasm/main.go` would return raw Go error strings to the JavaScript client. This has been **remediated** by introducing a structured `WasmError` type with a `code` and `message`, preventing the leakage of internal implementation details.
### Error Recovery
- [x] **Graceful degradation?**
- **Finding:** Yes. The `KDTree` constructor attempts to use the `gonum` backend if requested, but it gracefully falls back to the `linear` backend if the `gonum` build tag is not present or if the backend fails to initialize. This ensures the library remains functional even without the optimized backend.
- [ ] **Retry logic with backoff?**
- **Finding:** Not applicable. This is a computational library, not a networked service, so retry logic is not relevant.
- [ ] **Circuit breaker patterns?**
- **Finding:** Not applicable. This is not a networked service.
### User-Facing Errors
- [x] **Helpful without exposing internals?**
- **Finding:** Yes. The error messages are clear and actionable for developers (e.g., "inconsistent dimensionality in points") without revealing sensitive internal state.
- [x] **Consistent error format?**
- **Finding:** Yes. The Go API uses the standard `error` interface. The WASM API has been updated to use a consistent JSON structure for all errors: `{ "ok": false, "error": { "code": "...", "message": "..." } }`.
- [ ] **Localization support?**
- **Finding:** No. Error messages are in English. For a developer-facing library, this is generally acceptable and localization is not expected.
### API Errors
- [x] **Standard error response format?**
- **Finding:** Yes. As noted above, the WASM API now has a standardized JSON error format.
- [ ] **Appropriate HTTP status codes?**
- **Finding:** Not applicable. This is a WASM module, not an HTTP service.
- [x] **Error codes for clients?**
- **Finding:** Yes. The WASM API now includes standardized string-based error codes (e.g., `bad_request`, `not_found`), allowing clients to handle different error types programmatically.
## Logging
The library itself does not perform any logging, which is appropriate for its role. It returns errors to the calling application, which is then responsible for its own logging strategy. The library does include analytics and metrics collection (`kdtree_analytics.go`), but this is separate from logging and does not record any sensitive information.
### What is Logged
- [ ] Security events (auth, access)? - **N/A**
- [ ] Errors with context? - **N/A**
- [ ] Performance metrics? - **N/A** (collected, but not logged by the library)
### What Should NOT be Logged
- [ ] Passwords/tokens - **N/A**
- [ ] PII without consent - **N/A**
- [ ] Full credit card numbers - **N/A**
### Log Quality
- [ ] Structured logging (JSON)? - **N/A**
- [ ] Correlation IDs? - **N/A**
- [ ] Log levels used correctly? - **N/A**
### Log Security
- [ ] Injection-safe? - **N/A**
- [ ] Tamper-evident? - **N/A**
- [ ] Retention policy? - **N/A**

View file

@ -71,7 +71,9 @@ Explore runnable examples in the repository:
- examples/kdtree_2d_ping_hop - examples/kdtree_2d_ping_hop
- examples/kdtree_3d_ping_hop_geo - examples/kdtree_3d_ping_hop_geo
- examples/kdtree_4d_ping_hop_geo_score - examples/kdtree_4d_ping_hop_geo_score
- examples/dht_helpers (convenience wrappers for common DHT schemas)
- examples/wasm-browser (browser demo using the ESM loader) - examples/wasm-browser (browser demo using the ESM loader)
- examples/wasm-browser-ts (TypeScript + Vite local demo)
### KDTree performance and notes ### KDTree performance and notes
- Dual backend support: Linear (always available) and an optimized KD backend enabled when building with `-tags=gonum`. Linear is the default; with the `gonum` tag, the optimized backend becomes the default. - Dual backend support: Linear (always available) and an optimized KD backend enabled when building with `-tags=gonum`. Linear is the default; with the `gonum` tag, the optimized backend becomes the default.
@ -216,4 +218,21 @@ This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2)
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
## Coverage
- CI produces coverage summaries as artifacts on every push/PR:
- Default job: `coverage-summary.md` (from `coverage.out`)
- Gonum-tag job: `coverage-summary-gonum.md` (from `coverage-gonum.out`)
- Locally, you can generate and inspect coverage with the Makefile:
```bash
make cover # runs tests with race + coverage and prints the total
make coverfunc # prints per-function coverage
make cover-kdtree # filters coverage to kdtree.go
make coverhtml # writes coverage.html for visual inspection
```
Note: CI also uploads raw coverage profiles as artifacts (`coverage.out`, `coverage-gonum.out`).

View file

@ -6,6 +6,11 @@
// await tree.insert({ id: 'a', coords: [0,0], value: 'A' }); // await tree.insert({ id: 'a', coords: [0,0], value: 'A' });
// const res = await tree.nearest([0.1, 0.2]); // const res = await tree.nearest([0.1, 0.2]);
// --- Environment detection ---
const isBrowser = typeof window !== 'undefined';
const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;
// --- Browser-specific helpers ---
async function loadScriptOnce(src) { async function loadScriptOnce(src) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// If already present, resolve immediately // If already present, resolve immediately
@ -18,20 +23,44 @@ async function loadScriptOnce(src) {
}); });
} }
// --- Loader logic ---
async function ensureWasmExec(url) { async function ensureWasmExec(url) {
if (typeof window !== 'undefined' && typeof window.Go === 'function') return; if (typeof globalThis.Go === 'function') return;
await loadScriptOnce(url);
if (typeof window === 'undefined' || typeof window.Go !== 'function') { if (isBrowser) {
throw new Error('wasm_exec.js did not define window.Go'); await loadScriptOnce(url);
} else if (isNode) {
const { fileURLToPath } = await import('url');
const wasmExecPath = fileURLToPath(url);
await import(wasmExecPath);
} else {
throw new Error(`Unsupported environment: cannot load ${url}`);
}
if (typeof globalThis.Go !== 'function') {
throw new Error('wasm_exec.js did not define globalThis.Go');
} }
} }
function unwrap(result) { function unwrap(result) {
if (!result || typeof result !== 'object') throw new Error('bad result'); if (!result || typeof result !== 'object') {
if (result.ok) return result.data; throw new Error(`bad/unexpected result type from WASM: ${typeof result}`);
throw new Error(result.error || 'unknown error'); }
if (result.ok) {
return result.data;
}
// Handle structured errors, which may be nested
const errorPayload = result.error || result;
if (errorPayload && typeof errorPayload === 'object') {
const err = new Error(errorPayload.message || 'unknown WASM error');
err.code = errorPayload.code;
throw err;
}
// Fallback for simple string errors
throw new Error(errorPayload || 'unknown WASM error');
} }
function call(name, ...args) { function call(name, ...args) {
const fn = globalThis[name]; const fn = globalThis[name];
if (typeof fn !== 'function') throw new Error(`WASM function ${name} not found`); if (typeof fn !== 'function') throw new Error(`WASM function ${name} not found`);
@ -65,18 +94,32 @@ export async function init(options = {}) {
} = options; } = options;
await ensureWasmExec(wasmExecURL); await ensureWasmExec(wasmExecURL);
const go = new window.Go(); const go = new globalThis.Go();
let result; let result;
if (instantiateWasm) { if (instantiateWasm) {
const source = await fetch(wasmURL).then(r => r.arrayBuffer()); let source;
if (isBrowser) {
source = await fetch(wasmURL).then(r => r.arrayBuffer());
} else {
const fs = await import('fs/promises');
const { fileURLToPath } = await import('url');
source = await fs.readFile(fileURLToPath(wasmURL));
}
const inst = await instantiateWasm(source, go.importObject); const inst = await instantiateWasm(source, go.importObject);
result = { instance: inst }; result = { instance: inst };
} else if (WebAssembly.instantiateStreaming) { } else if (isBrowser && WebAssembly.instantiateStreaming) {
result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject); result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject);
} else { } else {
const resp = await fetch(wasmURL); let bytes;
const bytes = await resp.arrayBuffer(); if (isBrowser) {
const resp = await fetch(wasmURL);
bytes = await resp.arrayBuffer();
} else {
const fs = await import('fs/promises');
const { fileURLToPath } = await import('url');
bytes = await fs.readFile(fileURLToPath(wasmURL));
}
result = await WebAssembly.instantiate(bytes, go.importObject); result = await WebAssembly.instantiate(bytes, go.importObject);
} }

View file

@ -4,11 +4,11 @@
import { init } from './loader.js'; import { init } from './loader.js';
(async function () { (async function () {
let px;
try { try {
const px = await init({ px = await init({
// In CI, dist/ is placed at repo root via make wasm-build && make npm-pack wasmURL: new URL('./dist/poindexter.wasm', import.meta.url).toString(),
wasmURL: new URL('./dist/poindexter.wasm', import.meta.url).pathname, wasmExecURL: new URL('./dist/wasm_exec.js', import.meta.url).toString(),
wasmExecURL: new URL('./dist/wasm_exec.js', import.meta.url).pathname,
}); });
const ver = await px.version(); const ver = await px.version();
if (!ver || typeof ver !== 'string') throw new Error('version not string'); if (!ver || typeof ver !== 'string') throw new Error('version not string');
@ -17,10 +17,24 @@ import { init } from './loader.js';
await tree.insert({ id: 'a', coords: [0, 0], value: 'A' }); await tree.insert({ id: 'a', coords: [0, 0], value: 'A' });
await tree.insert({ id: 'b', coords: [1, 0], value: 'B' }); await tree.insert({ id: 'b', coords: [1, 0], value: 'B' });
const nn = await tree.nearest([0.9, 0.1]); const nn = await tree.nearest([0.9, 0.1]);
if (!nn || !nn.id) throw new Error('nearest failed'); if (!nn || !nn.point || !nn.point.id) throw new Error('nearest failed');
console.log('WASM smoke ok:', ver, 'nearest.id=', nn.id); console.log('WASM smoke ok:', ver, 'nearest.id=', nn.point.id);
} catch (err) { } catch (err) {
console.error('WASM smoke failed:', err); console.error('WASM smoke failed:', err);
process.exit(1); process.exit(1);
} }
// Test error handling
try {
await px.newTree(0);
console.error('Expected error from newTree(0) but got none');
process.exit(1);
} catch (err) {
if (err.code === 'bad_request' && err.message.includes('dimension')) {
console.log('WASM error handling ok:', err.code);
} else {
console.error('WASM smoke failed: unexpected error format:', err);
process.exit(1);
}
}
})(); })();

View file

@ -18,26 +18,62 @@ var (
nextTreeID = 1 nextTreeID = 1
) )
// WasmError provides a structured error for WASM responses.
type WasmError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *WasmError) Error() string {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// Standard error codes.
const (
ErrCodeBadRequest = "bad_request"
ErrCodeNotFound = "not_found"
ErrCodeConflict = "conflict"
)
func newErr(code, msg string) *WasmError {
return &WasmError{Code: code, Message: msg}
}
func newErrf(code, format string, args ...any) *WasmError {
return &WasmError{Code: code, Message: fmt.Sprintf(format, args...)}
}
func export(name string, fn func(this js.Value, args []js.Value) (any, error)) { func export(name string, fn func(this js.Value, args []js.Value) (any, error)) {
js.Global().Set(name, js.FuncOf(func(this js.Value, args []js.Value) any { js.Global().Set(name, js.FuncOf(func(this js.Value, args []js.Value) any {
res, err := fn(this, args) res, err := fn(this, args)
if err != nil { if err != nil {
return map[string]any{"ok": false, "error": err.Error()} var wasmErr *WasmError
if errors.As(err, &wasmErr) {
return map[string]any{
"ok": false,
"error": map[string]any{"code": wasmErr.Code, "message": wasmErr.Message},
}
}
// Fallback for generic errors
return map[string]any{
"ok": false,
"error": map[string]any{"code": ErrCodeBadRequest, "message": err.Error()},
}
} }
return map[string]any{"ok": true, "data": res} return map[string]any{"ok": true, "data": res}
})) }))
} }
func getInt(v js.Value, idx int) (int, error) { func getInt(args []js.Value, idx int, name string) (int, error) {
if len := v.Length(); len > idx { if len(args) > idx {
return v.Index(idx).Int(), nil return args[idx].Int(), nil
} }
return 0, errors.New("missing integer argument") return 0, newErrf(ErrCodeBadRequest, "missing integer argument: %s", name)
} }
func getFloatSlice(arg js.Value) ([]float64, error) { func getFloatSlice(arg js.Value, name string) ([]float64, error) {
if arg.IsUndefined() || arg.IsNull() { if arg.IsUndefined() || arg.IsNull() {
return nil, errors.New("coords/query is undefined or null") return nil, newErrf(ErrCodeBadRequest, "argument is undefined or null: %s", name)
} }
ln := arg.Length() ln := arg.Length()
res := make([]float64, ln) res := make([]float64, ln)
@ -47,6 +83,19 @@ func getFloatSlice(arg js.Value) ([]float64, error) {
return res, nil return res, nil
} }
// pointToJS converts a KDPoint to a JS-friendly map, ensuring slices are []any.
func pointToJS(p pd.KDPoint[string]) map[string]any {
coords := make([]any, len(p.Coords))
for i, c := range p.Coords {
coords[i] = c
}
return map[string]any{
"id": p.ID,
"coords": coords,
"value": p.Value,
}
}
func version(_ js.Value, _ []js.Value) (any, error) { func version(_ js.Value, _ []js.Value) (any, error) {
return pd.Version(), nil return pd.Version(), nil
} }
@ -61,15 +110,15 @@ func hello(_ js.Value, args []js.Value) (any, error) {
func newTree(_ js.Value, args []js.Value) (any, error) { func newTree(_ js.Value, args []js.Value) (any, error) {
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("newTree(dim) requires dim") return nil, newErr(ErrCodeBadRequest, "newTree(dim) requires 'dim' argument")
} }
dim := args[0].Int() dim := args[0].Int()
if dim <= 0 { if dim <= 0 {
return nil, pd.ErrZeroDim return nil, newErr(ErrCodeBadRequest, pd.ErrZeroDim.Error())
} }
t, err := pd.NewKDTreeFromDim[string](dim) t, err := pd.NewKDTreeFromDim[string](dim)
if err != nil { if err != nil {
return nil, err return nil, newErr(ErrCodeBadRequest, err.Error())
} }
id := nextTreeID id := nextTreeID
nextTreeID++ nextTreeID++
@ -78,81 +127,95 @@ func newTree(_ js.Value, args []js.Value) (any, error) {
} }
func treeLen(_ js.Value, args []js.Value) (any, error) { func treeLen(_ js.Value, args []js.Value) (any, error) {
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("len(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
return t.Len(), nil return t.Len(), nil
} }
func treeDim(_ js.Value, args []js.Value) (any, error) { func treeDim(_ js.Value, args []js.Value) (any, error) {
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("dim(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
return t.Dim(), nil return t.Dim(), nil
} }
func insert(_ js.Value, args []js.Value) (any, error) { func insert(_ js.Value, args []js.Value) (any, error) {
// insert(treeId, {id: string, coords: number[], value?: string}) // insert(treeId, {id: string, coords: number[], value?: string})
if len(args) < 2 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("insert(treeId, point)") if err != nil {
return nil, err
}
if len(args) < 2 {
return nil, newErr(ErrCodeBadRequest, "insert(treeId, point) requires 'point' argument")
} }
id := args[0].Int()
pt := args[1] pt := args[1]
pid := pt.Get("id").String() pid := pt.Get("id").String()
coords, err := getFloatSlice(pt.Get("coords")) coords, err := getFloatSlice(pt.Get("coords"), "point.coords")
if err != nil { if err != nil {
return nil, err return nil, err
} }
val := pt.Get("value").String() val := pt.Get("value").String()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
okIns := t.Insert(pd.KDPoint[string]{ID: pid, Coords: coords, Value: val}) if okIns := t.Insert(pd.KDPoint[string]{ID: pid, Coords: coords, Value: val}); !okIns {
return okIns, nil return nil, newErr(ErrCodeConflict, "failed to insert point: dimension mismatch or duplicate ID")
}
return true, nil
} }
func deleteByID(_ js.Value, args []js.Value) (any, error) { func deleteByID(_ js.Value, args []js.Value) (any, error) {
// deleteByID(treeId, id) // deleteByID(treeId, id)
if len(args) < 2 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("deleteByID(treeId, id)") if err != nil {
return nil, err
}
if len(args) < 2 {
return nil, newErr(ErrCodeBadRequest, "deleteByID(treeId, id) requires 'id' argument")
} }
id := args[0].Int()
pid := args[1].String() pid := args[1].String()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
return t.DeleteByID(pid), nil if !t.DeleteByID(pid) {
return nil, newErrf(ErrCodeNotFound, "point with id '%s' not found", pid)
}
return true, nil
} }
func nearest(_ js.Value, args []js.Value) (any, error) { func nearest(_ js.Value, args []js.Value) (any, error) {
// nearest(treeId, query:number[]) -> {point, dist, found} // nearest(treeId, query:number[]) -> {point, dist, found}
if len(args) < 2 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("nearest(treeId, query)") if err != nil {
return nil, err
} }
id := args[0].Int() if len(args) < 2 {
query, err := getFloatSlice(args[1]) return nil, newErr(ErrCodeBadRequest, "nearest(treeId, query) requires 'query' argument")
}
query, err := getFloatSlice(args[1], "query")
if err != nil { if err != nil {
return nil, err return nil, err
} }
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
p, d, found := t.Nearest(query) p, d, found := t.Nearest(query)
out := map[string]any{ out := map[string]any{
"point": map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}, "point": pointToJS(p),
"dist": d, "dist": d,
"found": found, "found": found,
} }
@ -161,65 +224,74 @@ func nearest(_ js.Value, args []js.Value) (any, error) {
func kNearest(_ js.Value, args []js.Value) (any, error) { func kNearest(_ js.Value, args []js.Value) (any, error) {
// kNearest(treeId, query:number[], k:int) -> {points:[...], dists:[...]} // kNearest(treeId, query:number[], k:int) -> {points:[...], dists:[...]}
if len(args) < 3 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("kNearest(treeId, query, k)") if err != nil {
} return nil, err
id := args[0].Int() }
query, err := getFloatSlice(args[1]) if len(args) < 3 {
return nil, newErr(ErrCodeBadRequest, "kNearest(treeId, query, k) requires 'query' and 'k' arguments")
}
query, err := getFloatSlice(args[1], "query")
if err != nil {
return nil, err
}
k, err := getInt(args, 2, "k")
if err != nil { if err != nil {
return nil, err return nil, err
} }
k := args[2].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
pts, dists := t.KNearest(query, k) pts, dists := t.KNearest(query, k)
jsPts := make([]any, len(pts)) jsPts := make([]any, len(pts))
for i, p := range pts { for i, p := range pts {
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} jsPts[i] = pointToJS(p)
} }
return map[string]any{"points": jsPts, "dists": dists}, nil return map[string]any{"points": jsPts, "dists": dists}, nil
} }
func radius(_ js.Value, args []js.Value) (any, error) { func radius(_ js.Value, args []js.Value) (any, error) {
// radius(treeId, query:number[], r:number) -> {points:[...], dists:[...]} // radius(treeId, query:number[], r:number) -> {points:[...], dists:[...]}
if len(args) < 3 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("radius(treeId, query, r)") if err != nil {
return nil, err
} }
id := args[0].Int() if len(args) < 3 {
query, err := getFloatSlice(args[1]) return nil, newErr(ErrCodeBadRequest, "radius(treeId, query, r) requires 'query' and 'r' arguments")
}
query, err := getFloatSlice(args[1], "query")
if err != nil { if err != nil {
return nil, err return nil, err
} }
r := args[2].Float() r := args[2].Float()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
pts, dists := t.Radius(query, r) pts, dists := t.Radius(query, r)
jsPts := make([]any, len(pts)) jsPts := make([]any, len(pts))
for i, p := range pts { for i, p := range pts {
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} jsPts[i] = pointToJS(p)
} }
return map[string]any{"points": jsPts, "dists": dists}, nil return map[string]any{"points": jsPts, "dists": dists}, nil
} }
func exportJSON(_ js.Value, args []js.Value) (any, error) { func exportJSON(_ js.Value, args []js.Value) (any, error) {
// exportJSON(treeId) -> string (all points) // exportJSON(treeId) -> string (all points)
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("exportJSON(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
// Export all points // Export all points
points := t.Points() points := t.Points()
jsPts := make([]any, len(points)) jsPts := make([]any, len(points))
for i, p := range points { for i, p := range points {
jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} jsPts[i] = pointToJS(p)
} }
m := map[string]any{ m := map[string]any{
"dim": t.Dim(), "dim": t.Dim(),
@ -233,13 +305,13 @@ func exportJSON(_ js.Value, args []js.Value) (any, error) {
func getAnalytics(_ js.Value, args []js.Value) (any, error) { func getAnalytics(_ js.Value, args []js.Value) (any, error) {
// getAnalytics(treeId) -> analytics snapshot // getAnalytics(treeId) -> analytics snapshot
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("getAnalytics(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
snap := t.GetAnalyticsSnapshot() snap := t.GetAnalyticsSnapshot()
return map[string]any{ return map[string]any{
@ -259,13 +331,13 @@ func getAnalytics(_ js.Value, args []js.Value) (any, error) {
func getPeerStats(_ js.Value, args []js.Value) (any, error) { func getPeerStats(_ js.Value, args []js.Value) (any, error) {
// getPeerStats(treeId) -> array of peer stats // getPeerStats(treeId) -> array of peer stats
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("getPeerStats(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
stats := t.GetPeerStats() stats := t.GetPeerStats()
jsStats := make([]any, len(stats)) jsStats := make([]any, len(stats))
@ -282,14 +354,17 @@ func getPeerStats(_ js.Value, args []js.Value) (any, error) {
func getTopPeers(_ js.Value, args []js.Value) (any, error) { func getTopPeers(_ js.Value, args []js.Value) (any, error) {
// getTopPeers(treeId, n) -> array of top n peer stats // getTopPeers(treeId, n) -> array of top n peer stats
if len(args) < 2 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("getTopPeers(treeId, n)") if err != nil {
return nil, err
}
n, err := getInt(args, 1, "n")
if err != nil {
return nil, err
} }
id := args[0].Int()
n := args[1].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
stats := t.GetTopPeers(n) stats := t.GetTopPeers(n)
jsStats := make([]any, len(stats)) jsStats := make([]any, len(stats))
@ -306,13 +381,13 @@ func getTopPeers(_ js.Value, args []js.Value) (any, error) {
func getAxisDistributions(_ js.Value, args []js.Value) (any, error) { func getAxisDistributions(_ js.Value, args []js.Value) (any, error) {
// getAxisDistributions(treeId, axisNames?: string[]) -> array of axis distribution stats // getAxisDistributions(treeId, axisNames?: string[]) -> array of axis distribution stats
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("getAxisDistributions(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
var axisNames []string var axisNames []string
@ -351,13 +426,13 @@ func getAxisDistributions(_ js.Value, args []js.Value) (any, error) {
func resetAnalytics(_ js.Value, args []js.Value) (any, error) { func resetAnalytics(_ js.Value, args []js.Value) (any, error) {
// resetAnalytics(treeId) -> resets all analytics // resetAnalytics(treeId) -> resets all analytics
if len(args) < 1 { id, err := getInt(args, 0, "treeId")
return nil, errors.New("resetAnalytics(treeId)") if err != nil {
return nil, err
} }
id := args[0].Int()
t, ok := treeRegistry[id] t, ok := treeRegistry[id]
if !ok { if !ok {
return nil, fmt.Errorf("unknown treeId %d", id) return nil, newErrf(ErrCodeNotFound, "unknown treeId %d", id)
} }
t.ResetAnalytics() t.ResetAnalytics()
return true, nil return true, nil
@ -366,9 +441,9 @@ func resetAnalytics(_ js.Value, args []js.Value) (any, error) {
func computeDistributionStats(_ js.Value, args []js.Value) (any, error) { func computeDistributionStats(_ js.Value, args []js.Value) (any, error) {
// computeDistributionStats(distances: number[]) -> distribution stats // computeDistributionStats(distances: number[]) -> distribution stats
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("computeDistributionStats(distances)") return nil, newErr(ErrCodeBadRequest, "computeDistributionStats(distances) requires 'distances' argument")
} }
distances, err := getFloatSlice(args[0]) distances, err := getFloatSlice(args[0], "distances")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -394,7 +469,7 @@ func computeDistributionStats(_ js.Value, args []js.Value) (any, error) {
func computePeerQualityScore(_ js.Value, args []js.Value) (any, error) { func computePeerQualityScore(_ js.Value, args []js.Value) (any, error) {
// computePeerQualityScore(metrics: NATRoutingMetrics, weights?: QualityWeights) -> score // computePeerQualityScore(metrics: NATRoutingMetrics, weights?: QualityWeights) -> score
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("computePeerQualityScore(metrics)") return nil, newErr(ErrCodeBadRequest, "computePeerQualityScore(metrics) requires 'metrics' argument")
} }
m := args[0] m := args[0]
metrics := pd.NATRoutingMetrics{ metrics := pd.NATRoutingMetrics{
@ -432,7 +507,7 @@ func computePeerQualityScore(_ js.Value, args []js.Value) (any, error) {
func computeTrustScore(_ js.Value, args []js.Value) (any, error) { func computeTrustScore(_ js.Value, args []js.Value) (any, error) {
// computeTrustScore(metrics: TrustMetrics) -> score // computeTrustScore(metrics: TrustMetrics) -> score
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("computeTrustScore(metrics)") return nil, newErr(ErrCodeBadRequest, "computeTrustScore(metrics) requires 'metrics' argument")
} }
m := args[0] m := args[0]
metrics := pd.TrustMetrics{ metrics := pd.TrustMetrics{
@ -482,9 +557,9 @@ func getDefaultPeerFeatureRanges(_ js.Value, _ []js.Value) (any, error) {
func normalizePeerFeatures(_ js.Value, args []js.Value) (any, error) { func normalizePeerFeatures(_ js.Value, args []js.Value) (any, error) {
// normalizePeerFeatures(features: number[], ranges?: FeatureRanges) -> number[] // normalizePeerFeatures(features: number[], ranges?: FeatureRanges) -> number[]
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("normalizePeerFeatures(features)") return nil, newErr(ErrCodeBadRequest, "normalizePeerFeatures(features) requires 'features' argument")
} }
features, err := getFloatSlice(args[0]) features, err := getFloatSlice(args[0], "features")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -512,13 +587,13 @@ func normalizePeerFeatures(_ js.Value, args []js.Value) (any, error) {
func weightedPeerFeatures(_ js.Value, args []js.Value) (any, error) { func weightedPeerFeatures(_ js.Value, args []js.Value) (any, error) {
// weightedPeerFeatures(normalized: number[], weights: number[]) -> number[] // weightedPeerFeatures(normalized: number[], weights: number[]) -> number[]
if len(args) < 2 { if len(args) < 2 {
return nil, errors.New("weightedPeerFeatures(normalized, weights)") return nil, newErr(ErrCodeBadRequest, "weightedPeerFeatures(normalized, weights) requires 'normalized' and 'weights' arguments")
} }
normalized, err := getFloatSlice(args[0]) normalized, err := getFloatSlice(args[0], "normalized")
if err != nil { if err != nil {
return nil, err return nil, err
} }
weights, err := getFloatSlice(args[1]) weights, err := getFloatSlice(args[1], "weights")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -534,7 +609,7 @@ func weightedPeerFeatures(_ js.Value, args []js.Value) (any, error) {
func getExternalToolLinks(_ js.Value, args []js.Value) (any, error) { func getExternalToolLinks(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinks(domain: string) -> ExternalToolLinks // getExternalToolLinks(domain: string) -> ExternalToolLinks
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("getExternalToolLinks(domain)") return nil, newErr(ErrCodeBadRequest, "getExternalToolLinks(domain) requires 'domain' argument")
} }
domain := args[0].String() domain := args[0].String()
links := pd.GetExternalToolLinks(domain) links := pd.GetExternalToolLinks(domain)
@ -544,7 +619,7 @@ func getExternalToolLinks(_ js.Value, args []js.Value) (any, error) {
func getExternalToolLinksIP(_ js.Value, args []js.Value) (any, error) { func getExternalToolLinksIP(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinksIP(ip: string) -> ExternalToolLinks // getExternalToolLinksIP(ip: string) -> ExternalToolLinks
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("getExternalToolLinksIP(ip)") return nil, newErr(ErrCodeBadRequest, "getExternalToolLinksIP(ip) requires 'ip' argument")
} }
ip := args[0].String() ip := args[0].String()
links := pd.GetExternalToolLinksIP(ip) links := pd.GetExternalToolLinksIP(ip)
@ -554,7 +629,7 @@ func getExternalToolLinksIP(_ js.Value, args []js.Value) (any, error) {
func getExternalToolLinksEmail(_ js.Value, args []js.Value) (any, error) { func getExternalToolLinksEmail(_ js.Value, args []js.Value) (any, error) {
// getExternalToolLinksEmail(emailOrDomain: string) -> ExternalToolLinks // getExternalToolLinksEmail(emailOrDomain: string) -> ExternalToolLinks
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("getExternalToolLinksEmail(emailOrDomain)") return nil, newErr(ErrCodeBadRequest, "getExternalToolLinksEmail(emailOrDomain) requires 'emailOrDomain' argument")
} }
emailOrDomain := args[0].String() emailOrDomain := args[0].String()
links := pd.GetExternalToolLinksEmail(emailOrDomain) links := pd.GetExternalToolLinksEmail(emailOrDomain)
@ -633,7 +708,7 @@ func getRDAPServers(_ js.Value, _ []js.Value) (any, error) {
func buildRDAPDomainURL(_ js.Value, args []js.Value) (any, error) { func buildRDAPDomainURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPDomainURL(domain: string) -> string // buildRDAPDomainURL(domain: string) -> string
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("buildRDAPDomainURL(domain)") return nil, newErr(ErrCodeBadRequest, "buildRDAPDomainURL(domain) requires 'domain' argument")
} }
domain := args[0].String() domain := args[0].String()
// Use universal RDAP redirector // Use universal RDAP redirector
@ -643,7 +718,7 @@ func buildRDAPDomainURL(_ js.Value, args []js.Value) (any, error) {
func buildRDAPIPURL(_ js.Value, args []js.Value) (any, error) { func buildRDAPIPURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPIPURL(ip: string) -> string // buildRDAPIPURL(ip: string) -> string
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("buildRDAPIPURL(ip)") return nil, newErr(ErrCodeBadRequest, "buildRDAPIPURL(ip) requires 'ip' argument")
} }
ip := args[0].String() ip := args[0].String()
return fmt.Sprintf("https://rdap.org/ip/%s", ip), nil return fmt.Sprintf("https://rdap.org/ip/%s", ip), nil
@ -652,7 +727,7 @@ func buildRDAPIPURL(_ js.Value, args []js.Value) (any, error) {
func buildRDAPASNURL(_ js.Value, args []js.Value) (any, error) { func buildRDAPASNURL(_ js.Value, args []js.Value) (any, error) {
// buildRDAPASNURL(asn: string) -> string // buildRDAPASNURL(asn: string) -> string
if len(args) < 1 { if len(args) < 1 {
return nil, errors.New("buildRDAPASNURL(asn)") return nil, newErr(ErrCodeBadRequest, "buildRDAPASNURL(asn) requires 'asn' argument")
} }
asn := args[0].String() asn := args[0].String()
// Normalize ASN // Normalize ASN