Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
042b47a4d9
|
|||
|
952ce0285b
|
|||
|
45856f39b4
|
417
docs/WASM_MOBILE_BUILD_PLAN.md
Normal file
417
docs/WASM_MOBILE_BUILD_PLAN.md
Normal 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
3
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
@@ -113,11 +113,11 @@ func TestMaxExpiryDuration(t *testing.T) {
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "valid expiry at exact limit",
|
||||
name: "expiry at exact limit rejected",
|
||||
maxExpiryDuration: "PT1H",
|
||||
eventExpiry: 3600, // exactly 1 hour
|
||||
eventExpiry: 3600, // exactly 1 hour - >= means this is rejected
|
||||
hasExpiryTag: true,
|
||||
expectAllow: true,
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "expiry exceeds limit",
|
||||
@@ -235,6 +235,79 @@ func TestMaxExpiryDurationPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test that max_expiry_duration only applies to writes, not reads
|
||||
func TestMaxExpiryDurationWriteOnly(t *testing.T) {
|
||||
signer, pubkey := generateTestKeypair(t)
|
||||
|
||||
// Policy with strict max_expiry_duration
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"rules": {
|
||||
"4": {
|
||||
"description": "DM events with expiry",
|
||||
"max_expiry_duration": "PT10M",
|
||||
"privileged": true
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
policy, err := New(policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy: %v", err)
|
||||
}
|
||||
|
||||
// Create event WITHOUT an expiry tag - this would fail write validation
|
||||
// but should still be readable
|
||||
ev := createTestEventForNewFields(t, signer, "test DM", 4)
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign: %v", err)
|
||||
}
|
||||
|
||||
// Write should fail (no expiry tag when max_expiry_duration is set)
|
||||
allowed, err := policy.CheckPolicy("write", ev, pubkey, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy write error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Write should be denied for event without expiry tag when max_expiry_duration is set")
|
||||
}
|
||||
|
||||
// Read should succeed (validation constraints don't apply to reads)
|
||||
allowed, err = policy.CheckPolicy("read", ev, pubkey, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy read error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Read should be allowed - max_expiry_duration is write-only validation")
|
||||
}
|
||||
|
||||
// Also test with an event that has expiry exceeding the limit
|
||||
ev2 := createTestEventForNewFields(t, signer, "test DM 2", 4)
|
||||
expiryTs := ev2.CreatedAt + 7200 // 2 hours - exceeds 10 minute limit
|
||||
addTagString(ev2, "expiration", int64ToString(expiryTs))
|
||||
if err := ev2.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign: %v", err)
|
||||
}
|
||||
|
||||
// Write should fail (expiry exceeds limit)
|
||||
allowed, err = policy.CheckPolicy("write", ev2, pubkey, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy write error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("Write should be denied for event with expiry exceeding max_expiry_duration")
|
||||
}
|
||||
|
||||
// Read should still succeed
|
||||
allowed, err = policy.CheckPolicy("read", ev2, pubkey, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy read error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("Read should be allowed - max_expiry_duration is write-only validation")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ProtectedRequired Tests
|
||||
// =============================================================================
|
||||
@@ -1071,6 +1144,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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1272,7 +1280,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check required tags
|
||||
if len(rule.MustHaveTags) > 0 {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && len(rule.MustHaveTags) > 0 {
|
||||
for _, requiredTag := range rule.MustHaveTags {
|
||||
if ev.Tags.GetFirst([]byte(requiredTag)) == nil {
|
||||
return false, nil
|
||||
@@ -1281,7 +1290,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check expiry time (uses maxExpirySeconds which is parsed from MaxExpiryDuration or MaxExpiry)
|
||||
if rule.maxExpirySeconds != nil && *rule.maxExpirySeconds > 0 {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && rule.maxExpirySeconds != nil && *rule.maxExpirySeconds > 0 {
|
||||
expiryTag := ev.Tags.GetFirst([]byte("expiration"))
|
||||
if expiryTag == nil {
|
||||
return false, nil // Must have expiry if max_expiry is set
|
||||
@@ -1294,7 +1304,7 @@ func (p *P) checkRulePolicy(
|
||||
return false, nil // Invalid expiry format
|
||||
}
|
||||
maxAllowedExpiry := ev.CreatedAt + *rule.maxExpirySeconds
|
||||
if expiryTs > maxAllowedExpiry {
|
||||
if expiryTs >= maxAllowedExpiry {
|
||||
log.D.F("expiration %d exceeds max allowed %d (created_at %d + max_expiry %d)",
|
||||
expiryTs, maxAllowedExpiry, ev.CreatedAt, *rule.maxExpirySeconds)
|
||||
return false, nil // Expiry too far in the future
|
||||
@@ -1302,7 +1312,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check ProtectedRequired (NIP-70: events must have "-" tag)
|
||||
if rule.ProtectedRequired {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && rule.ProtectedRequired {
|
||||
protectedTag := ev.Tags.GetFirst([]byte("-"))
|
||||
if protectedTag == nil {
|
||||
log.D.F("protected_required: event missing '-' tag (NIP-70)")
|
||||
@@ -1311,7 +1322,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check IdentifierRegex (validates "d" tag values)
|
||||
if rule.identifierRegexCache != nil {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && rule.identifierRegexCache != nil {
|
||||
dTags := ev.Tags.GetAll([]byte("d"))
|
||||
if len(dTags) == 0 {
|
||||
log.D.F("identifier_regex: event missing 'd' tag")
|
||||
@@ -1328,7 +1340,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check MaxAgeOfEvent (maximum age of event in seconds)
|
||||
if rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 {
|
||||
currentTime := time.Now().Unix()
|
||||
maxAllowedTime := currentTime - *rule.MaxAgeOfEvent
|
||||
if ev.CreatedAt < maxAllowedTime {
|
||||
@@ -1337,7 +1350,8 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check MaxAgeEventInFuture (maximum time event can be in the future in seconds)
|
||||
if rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 {
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 {
|
||||
currentTime := time.Now().Unix()
|
||||
maxFutureTime := currentTime + *rule.MaxAgeEventInFuture
|
||||
if ev.CreatedAt > maxFutureTime {
|
||||
@@ -1784,7 +1798,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 +1829,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.31.4
|
||||
v0.31.7
|
||||
|
||||
Reference in New Issue
Block a user