Compare commits

..

2 Commits

Author SHA1 Message Date
952ce0285b Validate ISO-8601 duration format for max_expiry_duration
Some checks failed
Go / build-and-release (push) Has been cancelled
Added validation to reject invalid max_expiry_duration formats in policy configs, ensuring compliance with ISO-8601 standards. Updated the `New` function to fail fast on invalid inputs and included detailed error messages for better clarity. Comprehensive tests were added to verify both valid and invalid scenarios.

bump tag to build binary with update
2025-12-02 11:53:52 +00:00
45856f39b4 Update nostr to v1.0.7 with cross-platform crypto support
Some checks failed
Go / build-and-release (push) Has been cancelled
- Bump git.mleku.dev/mleku/nostr from v1.0.4 to v1.0.7
- Add p256k1.mleku.dev as indirect dependency for pure Go crypto
- Remove local replace directive for CI compatibility
- Add WASM/Mobile build plan documentation
- Bump version to v0.31.5

nostr v1.0.7 changes:
- Split crypto/p8k into platform-specific files
- Linux uses libsecp256k1 via purego (fast)
- Other platforms (darwin, windows, android) use pure Go p256k1
- Enables cross-compilation without CGO or native libraries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:21:28 +00:00
6 changed files with 522 additions and 6 deletions

View File

@@ -0,0 +1,417 @@
# Plan: Enable js/wasm, iOS, and Android Builds
This document outlines the work required to enable ORLY and the nostr library to build successfully for WebAssembly (js/wasm), iOS (ios/arm64), and Android (android/arm64).
## Current Build Status
| Platform | Status | Notes |
|----------|--------|-------|
| linux/amd64 | ✅ Works | Uses libsecp256k1 via purego |
| darwin/arm64 | ✅ Works | Uses pure Go p256k1 |
| darwin/amd64 | ✅ Works | Uses pure Go p256k1 |
| windows/amd64 | ✅ Works | Uses pure Go p256k1 |
| android/arm64 | ✅ Works | Uses pure Go p256k1 |
| js/wasm | ❌ Fails | Missing platform stubs (planned for hackpadfs work) |
| ios/arm64 | ⚠️ Requires gomobile | See iOS section below |
---
## Issue 1: js/wasm Build Failures
### Problem
Two packages fail to compile for js/wasm due to missing platform-specific implementations:
1. **`next.orly.dev/pkg/utils/interrupt`** - Missing `Restart()` function
2. **`git.mleku.dev/mleku/nostr/ws`** - Missing `getConnectionOptions()` function
### Root Cause Analysis
#### 1.1 interrupt package
The `Restart()` function is defined with build tags:
- `restart.go``//go:build linux`
- `restart_darwin.go``//go:build darwin`
- `restart_windows.go``//go:build windows`
But `main.go` calls `Restart()` unconditionally on line 66, causing undefined symbol on js/wasm.
#### 1.2 ws package
The `getConnectionOptions()` function is defined in `connection_options.go` with:
```go
//go:build !js
```
This correctly excludes js/wasm, but no alternative implementation exists for js/wasm, so `connection.go` line 28 fails.
### Solution
#### 1.1 Fix interrupt package (ORLY)
Create a new file `restart_other.go`:
```go
//go:build !linux && !darwin && !windows
package interrupt
import (
"lol.mleku.dev/log"
"os"
)
// Restart is not supported on this platform - just exit
func Restart() {
log.W.Ln("restart not supported on this platform, exiting")
os.Exit(0)
}
```
#### 1.2 Fix ws package (nostr library)
Create a new file `connection_options_js.go`:
```go
//go:build js
package ws
import (
"crypto/tls"
"net/http"
)
// getConnectionOptions returns nil on js/wasm as we use browser WebSocket API
func getConnectionOptions(
requestHeader http.Header, tlsConfig *tls.Config,
) *websocket.Dialer {
// On js/wasm, gorilla/websocket doesn't work - need to use browser APIs
// This is a stub that allows compilation; actual WebSocket usage would
// need a js/wasm-compatible implementation
return nil
}
```
**However**, this alone won't make WebSocket work - the entire `ws` package uses `gorilla/websocket` which doesn't support js/wasm. A proper fix requires:
Option A: Use conditional compilation to swap in a js/wasm WebSocket implementation (e.g., `nhooyr.io/websocket` which supports js/wasm)
Option B: Make the `ws` package optional with build tags so js/wasm builds exclude it entirely
**Recommended**: Option B - exclude the ws client package on js/wasm since ORLY is a server, not a client.
---
## Issue 2: iOS Build Failure
### Problem
```
ios/arm64 requires external (cgo) linking, but cgo is not enabled
```
### Root Cause
iOS requires CGO for all executables due to Apple's linking requirements. This is a fundamental Go limitation - you cannot build iOS binaries with `CGO_ENABLED=0`.
### Solution
#### Option A: Accept CGO requirement for iOS
Build with CGO enabled and provide a cross-compilation toolchain:
```bash
CGO_ENABLED=1 CC=clang GOOS=ios GOARCH=arm64 go build
```
This requires:
1. Xcode with iOS SDK installed
2. Cross-compilation from macOS (or complex cross-toolchain setup)
#### Option B: Create a library instead of executable
Instead of building a standalone binary, build ORLY as a Go library that can be called from Swift/Objective-C:
```bash
CGO_ENABLED=1 GOOS=ios GOARCH=arm64 go build -buildmode=c-archive -o liborly.a
```
This creates a static library usable in iOS apps.
#### Option C: Use gomobile
Use the `gomobile` tool which handles iOS cross-compilation:
```bash
gomobile bind -target=ios ./pkg/...
```
**Recommendation**: Option A or B depending on use case. For a relay server, iOS support may not be practical anyway (iOS backgrounding restrictions, network limitations).
---
## Issue 3: Android Build Failure (RESOLVED)
### Problem
```
# github.com/ebitengine/purego
dlfcn_android.go:21:13: undefined: cgo.Dlopen
```
### Root Cause
Android uses the Linux kernel, so Go's `GOOS=android` still matches the `linux` build tag. This meant our `*_linux.go` files (which import purego) were being compiled for Android.
### Solution (Implemented)
Updated all build tags in `crypto/p8k/` to explicitly exclude Android:
**Linux files** (`*_linux.go`):
```go
//go:build linux && !android && !purego
```
**Other platform files** (`*_other.go`):
```go
//go:build !linux || android || purego
```
This ensures Android uses the pure Go `p256k1.mleku.dev` implementation instead of trying to load libsecp256k1 via purego.
### Verification
```bash
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build -o orly-android-arm64
# Successfully produces 33MB ARM64 ELF binary
```
---
## Implementation Plan
### Phase 1: js/wasm Support (Low effort)
| Task | Repository | Effort |
|------|------------|--------|
| Create `restart_other.go` stub | ORLY | 5 min |
| Create `connection_options_js.go` stub OR exclude ws package | nostr | 15 min |
| Test js/wasm build compiles | Both | 5 min |
**Note**: This enables *compilation* but not *functionality*. Running ORLY in a browser would require significant additional work (no filesystem, no listening sockets, etc.).
### Phase 2: Android Support (Medium effort)
| Task | Repository | Effort |
|------|------------|--------|
| Audit purego imports - ensure Linux-only | nostr | 30 min |
| Add build tags to any files importing purego | nostr | 15 min |
| Test android/arm64 build | Both | 5 min |
### Phase 3: iOS Support (High effort, questionable value)
| Task | Repository | Effort |
|------|------------|--------|
| Set up iOS cross-compilation environment | - | 2-4 hours |
| Modify build scripts for CGO_ENABLED=1 | ORLY | 30 min |
| Create c-archive or gomobile bindings | ORLY | 2-4 hours |
| Test on iOS simulator/device | - | 1-2 hours |
**Recommendation**: iOS support should be deprioritized unless there's a specific use case. A Nostr relay is a server, and iOS imposes severe restrictions on background network services.
---
## Quick Wins (Do First)
### 1. Create `restart_other.go` in ORLY
```go
//go:build !linux && !darwin && !windows
package interrupt
import (
"lol.mleku.dev/log"
"os"
)
func Restart() {
log.W.Ln("restart not supported on this platform, exiting")
os.Exit(0)
}
```
### 2. Exclude ws package from js/wasm in nostr library
Modify `connection.go` to have a build tag:
```go
//go:build !js
package ws
// ... rest of file
```
Create `connection_js.go`:
```go
//go:build js
package ws
// Stub package for js/wasm - WebSocket client not supported
// Use browser's native WebSocket API instead
```
### 3. Audit purego usage
Ensure all files that import `github.com/ebitengine/purego` have:
```go
//go:build linux && !purego
```
---
## Estimated Total Effort
| Platform | Compilation | Full Functionality |
|----------|-------------|-------------------|
| js/wasm | 1 hour | Not practical (server) |
| android/arm64 | 1-2 hours | Possible with NDK |
| ios/arm64 | 4-8 hours | Limited (iOS restrictions) |
---
---
## iOS with gomobile
Since iOS requires CGO and you cannot use Xcode without an Apple ID, the `gomobile` approach is the best option. This creates a framework that can be integrated into iOS apps.
### Prerequisites
1. **Install gomobile**:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
```
2. **Create a bindable package**:
gomobile can only bind packages that export types and functions suitable for mobile. You'll need to create a simplified API layer.
### Creating a Bindable API
Create a new package (e.g., `pkg/mobile/`) with a simplified interface:
```go
// pkg/mobile/relay.go
package mobile
import (
"context"
// ... minimal imports
)
// Relay represents an embedded Nostr relay
type Relay struct {
// internal fields
}
// NewRelay creates a new relay instance
func NewRelay(dataDir string, port int) (*Relay, error) {
// Initialize relay with mobile-friendly defaults
}
// Start begins accepting connections
func (r *Relay) Start() error {
// Start the relay server
}
// Stop gracefully shuts down the relay
func (r *Relay) Stop() error {
// Shutdown
}
// GetPublicKey returns the relay's public key
func (r *Relay) GetPublicKey() string {
// Return npub
}
```
### Building the iOS Framework
```bash
# Build iOS framework (requires macOS)
gomobile bind -target=ios -o ORLY.xcframework ./pkg/mobile
# This produces ORLY.xcframework which can be added to Xcode projects
```
### Limitations of gomobile
1. **Only certain types are bindable**:
- Basic types (int, float, string, bool, []byte)
- Structs with exported fields of bindable types
- Interfaces with methods using bindable types
- Error return values
2. **No channels or goroutines in API**:
The public API must be synchronous or use callbacks
3. **Callbacks require interfaces**:
```go
// Define callback interface
type EventHandler interface {
OnEvent(eventJSON string)
}
// Accept callback in API
func (r *Relay) SetEventHandler(h EventHandler) {
// Store and use callback
}
```
### Alternative: Building a Static Library
If you want more control, build as a C archive:
```bash
# From macOS with Xcode command line tools
CGO_ENABLED=1 GOOS=ios GOARCH=arm64 \
go build -buildmode=c-archive -o liborly.a ./pkg/mobile
# This produces:
# - liborly.a (static library)
# - liborly.h (C header file)
```
This can be linked into any iOS project using the C header.
### Recommended Next Steps for iOS
1. Create `pkg/mobile/` with a minimal, mobile-friendly API
2. Test gomobile binding on Linux first: `gomobile bind -target=android ./pkg/mobile`
3. Once Android binding works, the iOS binding will use the same API
4. Find someone with macOS to run `gomobile bind -target=ios`
---
## Appendix: File Changes Summary
### nostr Repository (`git.mleku.dev/mleku/nostr`) - COMPLETED
| File | Change |
|------|--------|
| `crypto/p8k/secp_linux.go` | Build tag: `linux && !android && !purego` |
| `crypto/p8k/schnorr_linux.go` | Build tag: `linux && !android && !purego` |
| `crypto/p8k/ecdh_linux.go` | Build tag: `linux && !android && !purego` |
| `crypto/p8k/recovery_linux.go` | Build tag: `linux && !android && !purego` |
| `crypto/p8k/utils_linux.go` | Build tag: `linux && !android && !purego` |
| `crypto/p8k/secp_other.go` | Build tag: `!linux \|\| android \|\| purego` |
| `crypto/p8k/schnorr_other.go` | Build tag: `!linux \|\| android \|\| purego` |
| `crypto/p8k/ecdh_other.go` | Build tag: `!linux \|\| android \|\| purego` |
| `crypto/p8k/recovery_other.go` | Build tag: `!linux \|\| android \|\| purego` |
| `crypto/p8k/utils_other.go` | Build tag: `!linux \|\| android \|\| purego` |
| `crypto/p8k/constants.go` | NEW - shared constants (no build tags) |
### ORLY Repository (`next.orly.dev`)
| File | Change |
|------|--------|
| `go.mod` | Added `replace` directive for local nostr library |
### Future Work (js/wasm)
| File | Action Needed |
|------|---------------|
| `pkg/utils/interrupt/restart_other.go` | CREATE - stub `Restart()` for unsupported platforms |
| `nostr/ws/connection.go` | MODIFY - add `//go:build !js` or exclude package |
| `nostr/ws/connection_js.go` | CREATE - stub for js/wasm |

3
go.mod
View File

@@ -3,7 +3,7 @@ module next.orly.dev
go 1.25.3
require (
git.mleku.dev/mleku/nostr v1.0.4
git.mleku.dev/mleku/nostr v1.0.7
github.com/adrg/xdg v0.5.3
github.com/dgraph-io/badger/v4 v4.8.0
github.com/dgraph-io/dgo/v230 v230.0.1
@@ -82,6 +82,7 @@ require (
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
p256k1.mleku.dev v1.0.3 // indirect
)
retract v1.0.3

6
go.sum
View File

@@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
git.mleku.dev/mleku/nostr v1.0.4 h1:QKJlqUubLPeMpYpxHODSvfSlL+F6UhjBiBuze9FGRKo=
git.mleku.dev/mleku/nostr v1.0.4/go.mod h1:swI7bWLc7yU1jd7PLCCIrIcUR3Ug5O+GPvpub/w6eTY=
git.mleku.dev/mleku/nostr v1.0.7 h1:BXWsAAiGu56JXR4rIn0kaVOE+RtMmA9MPvAs8y/BjnI=
git.mleku.dev/mleku/nostr v1.0.7/go.mod h1:iYTlg2WKJXJ0kcsM6QBGOJ0UDiJidMgL/i64cHyPjZc=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
@@ -302,3 +302,5 @@ lol.mleku.dev v1.0.5/go.mod h1:JlsqP0CZDLKRyd85XGcy79+ydSRqmFkrPzYFMYxQ+zs=
lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w=
lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
p256k1.mleku.dev v1.0.3 h1:2SBEH9XhNAotO1Ik8ejODjChTqc06Z/6ncQhrYkAdRA=
p256k1.mleku.dev v1.0.3/go.mod h1:cWkZlx6Tu7CTmIxonFbdjhdNfkY3VbjjY5TFEILiTnY=

View File

@@ -1071,6 +1071,94 @@ func TestNewFieldsInGlobalRule(t *testing.T) {
}
}
// =============================================================================
// New() Validation Tests - Ensures invalid configs fail at load time
// =============================================================================
// TestNewRejectsInvalidMaxExpiryDuration verifies that New() fails fast when
// given an invalid max_expiry_duration format like "T10M" instead of "PT10M".
// This prevents silent failures where constraints are ignored.
func TestNewRejectsInvalidMaxExpiryDuration(t *testing.T) {
tests := []struct {
name string
json string
expectError bool
errorMatch string
}{
{
name: "valid PT10M format accepted",
json: `{
"rules": {
"4": {"max_expiry_duration": "PT10M"}
}
}`,
expectError: false,
},
{
name: "invalid T10M format (missing P prefix) rejected",
json: `{
"rules": {
"4": {"max_expiry_duration": "T10M"}
}
}`,
expectError: true,
errorMatch: "max_expiry_duration",
},
{
name: "invalid 10M format (missing PT prefix) rejected",
json: `{
"rules": {
"4": {"max_expiry_duration": "10M"}
}
}`,
expectError: true,
errorMatch: "max_expiry_duration",
},
{
name: "valid P7D format accepted",
json: `{
"rules": {
"1": {"max_expiry_duration": "P7D"}
}
}`,
expectError: false,
},
{
name: "invalid 7D format (missing P prefix) rejected",
json: `{
"rules": {
"1": {"max_expiry_duration": "7D"}
}
}`,
expectError: true,
errorMatch: "max_expiry_duration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policy, err := New([]byte(tt.json))
if tt.expectError {
if err == nil {
t.Errorf("New() should have rejected invalid config, but returned policy: %+v", policy)
return
}
if tt.errorMatch != "" && !contains(err.Error(), tt.errorMatch) {
t.Errorf("Error %q should contain %q", err.Error(), tt.errorMatch)
}
} else {
if err != nil {
t.Errorf("New() unexpected error for valid config: %v", err)
}
if policy == nil {
t.Error("New() returned nil policy for valid config")
}
}
})
}
}
// =============================================================================
// ValidateJSON Tests for New Fields
// =============================================================================

View File

@@ -468,11 +468,19 @@ func (p *P) UnmarshalJSON(data []byte) error {
// New creates a new policy from JSON configuration.
// If policyJSON is empty, returns a policy with default settings.
// The default_policy field defaults to "allow" if not specified.
// Returns an error if the policy JSON contains invalid values (e.g., invalid
// ISO-8601 duration format for max_expiry_duration, invalid regex patterns, etc.).
func New(policyJSON []byte) (p *P, err error) {
p = &P{
DefaultPolicy: "allow", // Set default value
}
if len(policyJSON) > 0 {
// Validate JSON before loading to fail fast on invalid configurations.
// This prevents silent failures where invalid values (like "T10M" instead
// of "PT10M" for max_expiry_duration) are ignored and constraints don't apply.
if err = p.ValidateJSON(policyJSON); err != nil {
return nil, fmt.Errorf("policy validation failed: %v", err)
}
if err = json.Unmarshal(policyJSON, p); chk.E(err) {
return nil, fmt.Errorf("failed to unmarshal policy JSON: %v", err)
}
@@ -1784,7 +1792,7 @@ func (p *P) ValidateJSON(policyJSON []byte) error {
// Validate MaxExpiryDuration format
if rule.MaxExpiryDuration != "" {
if _, err := parseDuration(rule.MaxExpiryDuration); err != nil {
return fmt.Errorf("invalid max_expiry_duration %q in kind %d: %v", rule.MaxExpiryDuration, kind, err)
return fmt.Errorf("invalid max_expiry_duration %q in kind %d: %v (format must be ISO-8601 duration, e.g. \"PT10M\" for 10 minutes, \"P7D\" for 7 days, \"P1DT12H\" for 1 day 12 hours)", rule.MaxExpiryDuration, kind, err)
}
}
// Validate FollowsWhitelistAdmins pubkeys
@@ -1815,7 +1823,7 @@ func (p *P) ValidateJSON(policyJSON []byte) error {
// Validate global rule MaxExpiryDuration format
if tempPolicy.Global.MaxExpiryDuration != "" {
if _, err := parseDuration(tempPolicy.Global.MaxExpiryDuration); err != nil {
return fmt.Errorf("invalid max_expiry_duration %q in global rule: %v", tempPolicy.Global.MaxExpiryDuration, err)
return fmt.Errorf("invalid max_expiry_duration %q in global rule: %v (format must be ISO-8601 duration, e.g. \"PT10M\" for 10 minutes, \"P7D\" for 7 days, \"P1DT12H\" for 1 day 12 hours)", tempPolicy.Global.MaxExpiryDuration, err)
}
}

View File

@@ -1 +1 @@
v0.31.4
v0.31.6