Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .agents/skills/obol-stack-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ go test ./cmd/obol ./internal/tunnel ./internal/stack -count=1
go test ./cmd/obol ./internal/stack ./internal/hermes -count=1
```

Force a fresh local image build (otherwise `obol stack up` reuses any
locally-tagged `ghcr.io/obolnetwork/<name>:latest` and your source change
won't reach the running pod):

```bash
OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true obol stack up
```

Applies to every image in `baseLocalImages` (x402-verifier,
serviceoffer-controller, x402-buyer, demo-server, public-storefront).
The warm-path summary line surfaces this hint when nothing was rebuilt.

Integration checks:

```bash
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ Key code: `cmd/x402-buyer/`, `internal/x402/buyer/`, and `internal/x402/forwarda
1. **Absolute paths required** — Docker volume mounts need absolute paths (resolved at `obol stack init`)
2. **Two-stage templating** — Stage 1 (CLI flags) → Stage 2 (Helmfile) separation is critical
3. **Unique namespaces** — each deployment must have unique namespace
4. **`OBOL_DEVELOPMENT=true`** — required for `obol stack up` to auto-build local images (x402-verifier, serviceoffer-controller, x402-buyer)
4. **`OBOL_DEVELOPMENT=true`** — required for `obol stack up` to auto-build local images (x402-verifier, serviceoffer-controller, x402-buyer, demo-server, public-storefront). The build path reuses any locally-tagged image of the same name to keep warm runs fast; pass `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true` alongside it to force `docker build` for every image regardless of what's already in the local daemon. The "Local dev images ready" summary line surfaces this hint when nothing was rebuilt this run.
5. **Root-owned PVCs** — `-f` flag required to remove in `obol stack purge`
6. **Narrow review boundaries** — for controller/RBAC/payment changes, spell out exact security and user-journey invariants before editing or delegating; broad review prompts have previously produced noisy findings and missed test drift

Expand Down
6 changes: 6 additions & 0 deletions internal/stack/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,12 @@ func buildAndImportLocalImages(cfg *config.Config, u *ui.UI) {
u.Successf("Local dev images ready (%d built, %d pulled, %d imported, %d cached) (%s)",
built, pulled, imported, cached, elapsed)
}
// Surface the rebuild escape hatch on the warm path. When `built == 0`
// the dev may be wondering whether their latest source change actually
// landed in the running pods; the hint tells them how to force it.
if built == 0 && reuseCachedImages {
u.Dim(" Re-run with OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true to rebuild from source.")
}
}
}

Expand Down
27 changes: 21 additions & 6 deletions internal/x402/forwardauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ type ForwardAuthConfig struct {
// `eip2612GasSponsoring` (gasless Permit2 approve) so buyers take the
// matching flow. See BuildExtensionsForAsset for how this is populated.
Extensions map[string]any

// SendPaymentRequired, if non-nil, replaces the default JSON 402 renderer.
// The verifier injects NewHTMLAwarePaymentRequired here so browsers and
// link-preview scrapers receive an HTML page (with OG metadata + copyable
// "ways to pay" prompts) while x402-aware clients keep getting JSON.
// Nil keeps today's behaviour: every 402 is JSON.
SendPaymentRequired SendPaymentRequiredFunc
}

// facilitatorVerifyRequest is the JSON body sent to POST /verify and /settle.
Expand Down Expand Up @@ -98,11 +105,16 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
"cluster verifier.")
}

send := cfg.SendPaymentRequired
if send == nil {
send = sendPaymentRequiredJSON
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paymentHeader := r.Header.Get("X-PAYMENT")
if paymentHeader == "" {
sendPaymentRequired(w, r, requirements, cfg.Extensions)
send(w, r, requirements, cfg.Extensions)
return
}

Expand All @@ -124,7 +136,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa

matchedReq, found := findMatchingRequirementV1(payload, requirements)
if !found {
sendPaymentRequired(w, r, requirements, cfg.Extensions)
send(w, r, requirements, cfg.Extensions)
return
}

Expand All @@ -138,7 +150,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa

if !verifyResp.IsValid {
log.Printf("x402: payment invalid: %s", verifyResp.InvalidReason)
sendPaymentRequired(w, r, requirements, cfg.Extensions)
send(w, r, requirements, cfg.Extensions)
return
}

Expand All @@ -159,7 +171,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa

if !settleResp.Success {
log.Printf("x402: settlement unsuccessful: %s", settleResp.ErrorReason)
sendPaymentRequired(w, r, requirements, cfg.Extensions)
send(w, r, requirements, cfg.Extensions)
return false
}

Expand All @@ -178,8 +190,11 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa
}
}

// sendPaymentRequired writes a 402 response with v2 payment requirements.
func sendPaymentRequired(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) {
// sendPaymentRequiredJSON writes a 402 response with v2 payment requirements
// as a JSON body. This is the wire-level x402 contract that all buyer agents
// understand; it remains the default when ForwardAuthConfig.SendPaymentRequired
// is unset and the fallback when the renderer has nothing else to do.
func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) {
resource := &x402types.ResourceInfo{
URL: buildResourceURL(r),
Description: "Payment required for " + r.URL.Path,
Expand Down
Loading
Loading