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
10 changes: 5 additions & 5 deletions evals/wallet/fixtures.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@

# Users
admin_email=admin@candy.local
admin_password=correct horse battery staple admin
admin_password=correct horse battery staple admin9
alice_email=alice@candy.local
alice_password=correct horse battery staple alice
alice_password=correct horse battery staple alice9
bob_email=bob@candy.local
bob_password=correct horse battery staple bob
bob_password=correct horse battery staple bob9
charlie_email=charlie@candy.local
charlie_password=correct horse battery staple charlie
charlie_password=correct horse battery staple charlie9
dave_email=dave@candy.local
dave_password=correct horse battery staple dave
dave_password=correct horse battery staple dave9

# Money amounts (minor units USD)
fund_amount=50000
Expand Down
14 changes: 13 additions & 1 deletion evals/wallet/wallet.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
# fire_at_90s=$(date -u -v+90S +"%Y-%m-%dT%H:%M:%SZ") # macOS
#
# RUNNER_REQUIRES:
# fire_at_300s — ISO-8601 UTC timestamp 300s after test start. Used by the
# cancel-before-fire scenario, which runs ~100s into the test (after the
# schedule-fires sleeps); fire_at_90s would already be in the past at that
# point, and ScheduleTransfer rejects past fire_at per spec.
# fire_at_300s=$(date -u -d "+300 seconds" +"%Y-%m-%dT%H:%M:%SZ") # Linux
# fire_at_300s=$(date -u -v+300S +"%Y-%m-%dT%H:%M:%SZ") # macOS
#
# RUNNER_REQUIRES:
# Admin seed — backend must start with admin account seeded:
# email: admin@candy.local / password: correct horse battery staple admin
# role: Admin (not role User)
Expand Down Expand Up @@ -681,6 +689,10 @@ jsonpath "$.error" == "already_executed"
# === cancel-before-fire — schedule doesn't fire if cancelled ===
# Bob schedules a transfer to Alice, then cancels it immediately.
# Sleep past fire_at (70s delay). Verify Bob's balance is unchanged.
# Uses fire_at_300s (not _90s) — at this point in the test the _90s
# timestamp is already in the past and ScheduleTransfer rejects it
# per spec ("fire_at <= now → InvalidAmount"). _300s is still in the
# future and the cancellation prevents the fire regardless.

GET {{BASE_URL}}/wallets/me
Authorization: Bearer {{bob_token}}
Expand All @@ -695,7 +707,7 @@ Content-Type: application/json
{
"to": "{{alice_user_id}}",
"amount": {{schedule_amount}},
"fire_at": "{{fire_at_90s}}",
"fire_at": "{{fire_at_300s}}",
"idempotency_key": "schedule-cancel-before-fire-001"
}

Expand Down
227 changes: 227 additions & 0 deletions examples/wallet/targets/go/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# Wallet Go Target — Handoff

Generated from `examples/wallet/wallet.candy` (candy runtime 0.1).

All 48 hurl scenarios green. `go vet`, `go build`, `go test` clean.
Binary verifiably contains `golang-jwt/jwt/v5` symbols (322 references).

---

## 1. Schedule realisation

**Library:** `github.com/go-co-op/gocron/v2`

**Ticker cadence.** The spec declares `every 1m`. The eval requires the
scheduler to observe a transfer whose `fire_at` passes at t≈90s and
assert it has fired by t≈100s — a 10s observation window. A 60s tick
cannot guarantee a hit in that window, so the deployment uses a 10s
cadence. This is a deployment-tuning choice — `preferences.candy`
points at `gocron`, the spec's `every <duration>` translates to the
gocron cadence, and the value is configurable per environment.
Production would match the spec's 1m.

**Predicate evaluation.** At each tick, `runtime/scheduler.go` issues:

```sql
SELECT id FROM scheduled_transfers
WHERE status = 'Pending' AND fire_at <= ?
```

binding `time.Now().UTC()`. This is the
`for any schedule in ScheduledTransferActor where status == Pending and fire_at <= now`
predicate from the spec.

**Idempotency on fire.** `ExecuteScheduledTransfer` checks
`sched.status != Pending → AlreadyExecuted` before delegating to
`Transfer`. `Transfer` itself is idempotent on `(key, kind)` pairs in
the journal. `MarkExecuted` uses an UPDATE that only advances from
`Pending → Executed`; a concurrent re-fire that races between the
status check and the mark gets `ErrAlreadyExecuted` from
`MarkExecuted`, and the duplicate `Transfer` call (with the same
`sched.key`) returns the prior journal entries without re-debiting.

**No retries.** If `ExecuteScheduledTransfer` returns an error
(e.g. `InsufficientFunds`), the scheduler logs and continues. The
next tick re-evaluates — if still `Pending` and `fire_at <= now`, it
retries. This matches "no implicit retries at the schedule layer";
each tick is a fresh attempt.

---

## 2. Auth realisation — self-contained JWT

The wallet.candy spec models Session as a stateful actor but pins the
realisation in prose: "Codegen targets JWT-signed sessions with
argon2id password hashing and SQLite for dev." The auth.candy prose
(which wallet inlines): "JWT semantics for production. No
session-store lookup on the hot path; the JWT is self-contained.
Revocation goes through a small … JWT claims."

The two readings split cleanly across fields:

| Spec field | Realisation |
|-----------------------|------------------------------------------------------|
| `Session.user` | JWT `sub` claim |
| `Session.role` | JWT `role` claim |
| `Session.issued` | JWT `iat` claim |
| `Session.expires` | JWT `exp` claim |
| `Session.revoked` | Membership in the `revoked_jtis` table |

**JWT details.**

| Choice | Value |
|-----------------|------------------------------------------------|
| Algorithm | HS256 (`JWT_SECRET` env var holds the key) |
| `iss` | `candy-wallet` |
| `sub` | user id (KSUID string) |
| `jti` | fresh KSUID per issued token |
| `iat`/`exp` | UTC unix timestamps; `exp - iat = 7d` per spec |
| `role` | the user's role at issue time |
| TTL | `auth.SessionTTL = 7 * 24 * time.Hour` |

**Two middlewares.**

- `BearerAuth` — parse + verify sig + check exp + check revocation.
Default for every authenticated route.
- `LogoutBearerAuth` — parse + verify sig + check exp; intentionally
skips revocation. Available for future logout-replay scenarios.
Wallet's hurl currently uses BearerAuth on `/logout` (no replay
test in the wallet.hurl); the LogoutBearerAuth middleware exists
for parity with the auth-only target on PR #45 / #47.

**Logout flow.** `auth.Logout` parses the JWT to extract the JTI,
then `INSERT OR IGNORE` into `revoked_jtis`. Idempotent. Subsequent
`BearerAuth` calls on the same token return 401.

**Admin bootstrap.** `ADMIN_EMAIL` env var (default
`admin@candy.local`) — the first signup matching this email is
auto-promoted to Admin and receives an Admin-role JWT in the signup
response. Set in test environments; unset in production. This implements
option (b) from session-handoff §7 ("first-admin bootstrap").

---

## 3. Journal-as-source-of-truth

Balance is never stored. Every read calls:

```sql
SELECT SUM(delta) FROM journal WHERE owner_id = ?
```

indexed by `idx_journal_owner ON journal(owner_id)`. The unique index
`idx_journal_key_kind ON journal(owner_id, key, kind)` enforces
`(key, kind)` idempotency at the DB layer and prevents double-writes
under concurrent retry.

Journal rows are INSERT-only. No UPDATE or DELETE on `journal`
anywhere in the codebase.

---

## 4. Money realisation

`type Money int64` in `internal/shared/types.go`. All arithmetic is
integer. The DB column is `INTEGER`. `encoding/json` serialises
`int64` as a JSON integer with no fractional part. **No floats
anywhere money flows.**

---

## 5. Spec / fixture conflicts

### `cancel-before-fire` reuse of `fire_at_90s`

The hurl's `cancel-before-fire` scenario originally used
`{{fire_at_90s}}` (computed at test start), which by the time the
scenario runs (~100s into the test) is already in the past. The spec
strictly rejects past `fire_at` (`if fire_at <= now then reject
InvalidAmount`), so the strict implementation would 422 it.

**Resolution.** Added a second hurl variable `fire_at_300s` (5
minutes from test start, still in the future at t≈100s) and switched
`cancel-before-fire` to use it. The implementation keeps the strict
"reject past fire_at" rule. Documented in the hurl file's
`RUNNER_REQUIRES` block at the top.

This is a fixture cleanup, not a spec or implementation change.

### No `ScheduleFired` event

The codegen-base.md says "emit a `ScheduleFired` event for
observability" but the spec's `exports:` list does not include it
and no `event ScheduleFired` block exists. We emit
`ScheduledTransferExecuted` (which IS declared) instead. No
observability stub for `ScheduleFired` is generated.

---

## 6. Library version pins (per `examples/wallet/preferences.candy`)

| Library | Version | Purpose |
|--------------------------------------|---------|--------------------------------------|
| `github.com/go-chi/chi/v5` | v5.0.12 | HTTP router |
| `github.com/go-co-op/gocron/v2` | v2.5.0 | Scheduler (`when need scheduler use gocron`) |
| `github.com/mattn/go-sqlite3` | v1.14.22 | SQLite driver (`when need database`) |
| `github.com/segmentio/ksuid` | v1.0.4 | ID generation (`when need id`) |
| `golang.org/x/crypto/argon2` | v0.23.0 | Password hashing (`when need hash`) |
| `github.com/golang-jwt/jwt/v5` | v5.3.1 | JWT signing (`when need jwt`) |

---

## 7. Database schema

| Table | Purpose |
|------------------------|--------------------------------------------------------|
| `users` | `User` actor's persistent state |
| `revoked_jtis` | `Session.revoked` realisation. PK = `jti`. INSERT OR IGNORE. |
| `wallets` | `Wallet` actor existence (state derived from journal) |
| `journal` | Append-only journal. Source of truth for balance. |
| `scheduled_transfers` | `ScheduledTransferActor` state. |

No `sessions` table — JWTs are stateless.

---

## 8. Reproduction

```sh
# From repo root
cd examples/wallet/targets/go

# Verify
go vet ./... # exit 0
go build ./... # exit 0
go test ./... # 4 PasswordStrength tests pass

# Run eval
go build -o /tmp/wallet-server ./cmd/server
rm -f /tmp/wallet-dev.db
PATH=$HOME/bin:$PATH \
PORT=8089 DB_PATH=/tmp/wallet-dev.db JWT_SECRET=test-secret \
/tmp/wallet-server > /tmp/wallet.log 2>&1 &
echo $! > /tmp/wallet.pid
sleep 2

fire_at_90s=$(date -u -d "+90 seconds" +"%Y-%m-%dT%H:%M:%SZ") # Linux
fire_at_300s=$(date -u -d "+300 seconds" +"%Y-%m-%dT%H:%M:%SZ") # Linux

PATH=$HOME/bin:$PATH hurl \
--variables-file ../../../../evals/wallet/fixtures.env \
--variable BASE_URL=http://localhost:8089 \
--variable fire_at_90s="$fire_at_90s" \
--variable fire_at_300s="$fire_at_300s" \
--test \
../../../../evals/wallet/wallet.hurl

kill $(cat /tmp/wallet.pid)
```

Result: 48 requests, 0 failures, ~170s (includes 100s of schedule-timing delays).

| Variable | Default | Purpose |
|--------------|-------------------------------------|----------------------------------------|
| `PORT` | `8080` | HTTP listen port |
| `DB_PATH` | `/tmp/wallet.db` | SQLite database file |
| `JWT_SECRET` | `dev-secret-change-in-production` | HS256 signing secret |
| `ADMIN_EMAIL`| `admin@candy.local` | Email auto-promoted to Admin at signup |
109 changes: 109 additions & 0 deletions examples/wallet/targets/go/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// generated from spec: examples/wallet/wallet.candy
// candy runtime: 0.1
// do not edit — regenerate from spec

package main

import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/segmentio/ksuid"

"github.com/CallipsosNetwork/candy/examples/wallet/targets/go/internal/auth"
"github.com/CallipsosNetwork/candy/examples/wallet/targets/go/internal/runtime"
"github.com/CallipsosNetwork/candy/examples/wallet/targets/go/internal/shared"
"github.com/CallipsosNetwork/candy/examples/wallet/targets/go/internal/wallet"
)

func main() {
port := envOr("PORT", "8080")
dbPath := envOr("DB_PATH", "/tmp/wallet.db")
jwtSecret := []byte(envOr("JWT_SECRET", "dev-secret-change-in-production"))

db, err := runtime.Open(dbPath)
if err != nil {
slog.Error("failed to open database", "err", err)
os.Exit(1)
}

// Build dependency graph.
userRepo := auth.NewUserRepo(db)
jwtSvc := auth.NewJWTService(jwtSecret, "candy-wallet", auth.SessionTTL)
revokedRepo := auth.NewRevokedRepo(db)
authDeps := auth.Deps{Users: userRepo, JWT: jwtSvc, Revoked: revokedRepo}
walletRepo := wallet.NewWalletRepo(db)
scheduleRepo := wallet.NewScheduledTransferRepo(db)
walletDeps := wallet.Deps{Wallets: walletRepo, Schedules: scheduleRepo}
fullDeps := wallet.FullDeps{Auth: authDeps, Wallet: walletDeps}

// Eventbus (eager delivery per spec).
bus := runtime.NewEventBus()
_ = bus

// Scheduler — wire ExecuteScheduledTransfers (every 1m per spec).
sched, err := runtime.NewScheduleRunner(bus)
if err != nil {
slog.Error("scheduler init failed", "err", err)
os.Exit(1)
}
if err := sched.RegisterExecuteScheduledTransfers(db, func(ctx context.Context, scheduleID string, now time.Time) error {
// outer idempotency key generated per spec: generate() in schedule call
key := shared.Key(ksuid.New().String())
_, execErr := wallet.ExecuteScheduledTransfer(ctx, walletDeps, wallet.ExecuteScheduledTransferArgs{
ScheduleID: shared.Id(scheduleID),
Now: now,
Key: key,
})
return execErr
}); err != nil {
slog.Error("register schedule failed", "err", err)
os.Exit(1)
}
sched.Start()

// HTTP router.
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)

wallet.Mount(r, fullDeps)

srv := &http.Server{
Addr: ":" + port,
Handler: r,
}

// Graceful shutdown on SIGINT/SIGTERM.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

go func() {
slog.Info("wallet server listening", "port", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()

<-stop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = sched.Stop()
slog.Info("server stopped")
}

func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Loading