Add wallet session lifecycle endpoints and docs
This commit is contained in:
parent
a711ba36b6
commit
f1eb34f5b3
@ -29,6 +29,8 @@ Copy `.env.example` in this folder and set contract/runtime values before deploy
|
|||||||
|
|
||||||
- `POST /secret/wallet/intent`
|
- `POST /secret/wallet/intent`
|
||||||
- `POST /secret/wallet/verify`
|
- `POST /secret/wallet/verify`
|
||||||
|
- `POST /secret/wallet/session/refresh`
|
||||||
|
- `POST /secret/wallet/session/revoke`
|
||||||
- `POST /secret/membership/quote`
|
- `POST /secret/membership/quote`
|
||||||
- `POST /secret/membership/confirm`
|
- `POST /secret/membership/confirm`
|
||||||
- `GET /secret/membership/status`
|
- `GET /secret/membership/status`
|
||||||
@ -71,6 +73,11 @@ When `SECRET_API_REQUIRE_WALLET_SESSION=true`, wallet-scoped control-plane endpo
|
|||||||
|
|
||||||
Covered endpoints include marketplace checkout/entitlements, governance install/lease actions, and member-channel calls.
|
Covered endpoints include marketplace checkout/entitlements, governance install/lease actions, and member-channel calls.
|
||||||
|
|
||||||
|
Session lifecycle endpoints:
|
||||||
|
|
||||||
|
1. `POST /secret/wallet/session/refresh`: rotates the current session token and revokes the prior token.
|
||||||
|
2. `POST /secret/wallet/session/revoke`: revokes the current token immediately.
|
||||||
|
|
||||||
## Sponsorship Behavior
|
## Sponsorship Behavior
|
||||||
|
|
||||||
Membership quote supports ownership wallet and distinct payer wallet:
|
Membership quote supports ownership wallet and distinct payer wallet:
|
||||||
|
|||||||
@ -34,6 +34,8 @@ func (a *app) routes() http.Handler {
|
|||||||
mux.HandleFunc("/healthz", a.withCORS(a.handleHealth))
|
mux.HandleFunc("/healthz", a.withCORS(a.handleHealth))
|
||||||
mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent))
|
mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent))
|
||||||
mux.HandleFunc("/secret/wallet/verify", a.withCORS(a.handleWalletVerify))
|
mux.HandleFunc("/secret/wallet/verify", a.withCORS(a.handleWalletVerify))
|
||||||
|
mux.HandleFunc("/secret/wallet/session/refresh", a.withCORS(a.handleWalletSessionRefresh))
|
||||||
|
mux.HandleFunc("/secret/wallet/session/revoke", a.withCORS(a.handleWalletSessionRevoke))
|
||||||
mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote))
|
mux.HandleFunc("/secret/membership/quote", a.withCORS(a.handleMembershipQuote))
|
||||||
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
|
mux.HandleFunc("/secret/membership/confirm", a.withCORS(a.handleMembershipConfirm))
|
||||||
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
|
mux.HandleFunc("/secret/membership/status", a.withCORS(a.handleMembershipStatus))
|
||||||
@ -230,6 +232,97 @@ func (a *app) handleWalletVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) handleWalletSessionRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req walletSessionRefreshRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wallet, err := normalizeAddress(req.Wallet)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldToken := sessionTokenFromRequest(r)
|
||||||
|
if strings.TrimSpace(oldToken) == "" {
|
||||||
|
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.enforceWalletSession(w, r, wallet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldSession, err := a.store.getWalletSession(r.Context(), oldToken)
|
||||||
|
if err != nil {
|
||||||
|
if err == errNotFound {
|
||||||
|
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to resolve wallet session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := a.store.revokeWalletSession(r.Context(), oldToken, now); err != nil && err != errNotFound {
|
||||||
|
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke old wallet session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newSession, err := a.issueWalletSession(r.Context(), wallet, oldSession.DesignationCode)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorCode(w, http.StatusInternalServerError, "session_issue_failed", "failed to issue wallet session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set(sessionHeaderToken, newSession.SessionToken)
|
||||||
|
w.Header().Set(sessionHeaderExpiresAt, newSession.ExpiresAt.UTC().Format(time.RFC3339Nano))
|
||||||
|
writeJSON(w, http.StatusOK, walletSessionRefreshResponse{
|
||||||
|
Status: "session_refreshed",
|
||||||
|
Wallet: wallet,
|
||||||
|
SessionToken: newSession.SessionToken,
|
||||||
|
SessionExpire: newSession.ExpiresAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleWalletSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req walletSessionRevokeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErrorCode(w, http.StatusBadRequest, "invalid_request", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wallet, err := normalizeAddress(req.Wallet)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorCode(w, http.StatusBadRequest, "invalid_wallet", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionToken := sessionTokenFromRequest(r)
|
||||||
|
if strings.TrimSpace(sessionToken) == "" {
|
||||||
|
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_required", "wallet session required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.enforceWalletSession(w, r, wallet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := a.store.revokeWalletSession(r.Context(), sessionToken, now); err != nil {
|
||||||
|
if err == errNotFound {
|
||||||
|
writeErrorCode(w, http.StatusUnauthorized, "wallet_session_invalid", "wallet session not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrorCode(w, http.StatusInternalServerError, "store_error", "failed to revoke wallet session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, walletSessionRevokeResponse{
|
||||||
|
Status: "session_revoked",
|
||||||
|
Wallet: wallet,
|
||||||
|
RevokedAt: now.Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleMembershipQuote(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
@ -1476,4 +1569,7 @@ func isTxHash(value string) bool {
|
|||||||
|
|
||||||
func logConfig(cfg Config) {
|
func logConfig(cfg Config) {
|
||||||
log.Printf("secret api listening on %s chain_id=%d contract=%s currency=%s amount_atomic=%s", cfg.ListenAddr, cfg.ChainID, cfg.MembershipContract, cfg.MintCurrency, cfg.MintAmountAtomic)
|
log.Printf("secret api listening on %s chain_id=%d contract=%s currency=%s amount_atomic=%s", cfg.ListenAddr, cfg.ChainID, cfg.MembershipContract, cfg.MintCurrency, cfg.MintAmountAtomic)
|
||||||
|
if !cfg.RequireWalletSession {
|
||||||
|
log.Printf("warning: wallet session enforcement is disabled (SECRET_API_REQUIRE_WALLET_SESSION=false)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1210,6 +1210,314 @@ func TestWalletSessionMismatchBlocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWalletVerifyIssuesSessionToken(t *testing.T) {
|
||||||
|
a, cfg, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
intentRes := postJSONExpect[tWalletIntentResponse](t, a, "/secret/wallet/intent", walletIntentRequest{
|
||||||
|
Address: ownerAddr,
|
||||||
|
Origin: "https://edut.ai",
|
||||||
|
Locale: "en",
|
||||||
|
ChainID: cfg.ChainID,
|
||||||
|
}, http.StatusOK)
|
||||||
|
issuedAt, err := time.Parse(time.RFC3339Nano, intentRes.IssuedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse issued_at: %v", err)
|
||||||
|
}
|
||||||
|
td := buildTypedData(cfg, designationRecord{
|
||||||
|
Code: intentRes.DesignationCode,
|
||||||
|
DisplayToken: intentRes.DisplayToken,
|
||||||
|
Nonce: intentRes.Nonce,
|
||||||
|
IssuedAt: issuedAt,
|
||||||
|
Origin: "https://edut.ai",
|
||||||
|
})
|
||||||
|
sig := signTypedData(t, ownerKey, td)
|
||||||
|
verifyRes := postJSONExpect[tWalletVerifyResponse](t, a, "/secret/wallet/verify", walletVerifyRequest{
|
||||||
|
IntentID: intentRes.IntentID,
|
||||||
|
Address: ownerAddr,
|
||||||
|
ChainID: cfg.ChainID,
|
||||||
|
Signature: sig,
|
||||||
|
}, http.StatusOK)
|
||||||
|
if strings.TrimSpace(verifyRes.SessionToken) == "" {
|
||||||
|
t.Fatalf("expected wallet verify to issue session token: %+v", verifyRes)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(verifyRes.SessionExpiresAt) == "" {
|
||||||
|
t.Fatalf("expected wallet verify to return session expiry: %+v", verifyRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletSessionInvalidBlocked(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusUnauthorized, map[string]string{
|
||||||
|
sessionHeaderToken: "deadbeef",
|
||||||
|
})
|
||||||
|
if code := errResp["code"]; code != "wallet_session_invalid" {
|
||||||
|
t.Fatalf("expected wallet_session_invalid, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletSessionExpiredBlocked(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
session := walletSessionRecord{
|
||||||
|
SessionToken: "expired-session-token",
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
DesignationCode: "1234567890123",
|
||||||
|
ChainID: a.cfg.ChainID,
|
||||||
|
IssuedAt: now.Add(-2 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-1 * time.Minute),
|
||||||
|
}
|
||||||
|
if err := a.store.putWalletSession(context.Background(), session); err != nil {
|
||||||
|
t.Fatalf("seed wallet session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusUnauthorized, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if code := errResp["code"]; code != "wallet_session_expired" {
|
||||||
|
t.Fatalf("expected wallet_session_expired, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletSessionRevokedBlocked(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
session := walletSessionRecord{
|
||||||
|
SessionToken: "revoked-session-token",
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
DesignationCode: "1234567890123",
|
||||||
|
ChainID: a.cfg.ChainID,
|
||||||
|
IssuedAt: now.Add(-1 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(1 * time.Hour),
|
||||||
|
RevokedAt: &now,
|
||||||
|
}
|
||||||
|
if err := a.store.putWalletSession(context.Background(), session); err != nil {
|
||||||
|
t.Fatalf("seed wallet session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusUnauthorized, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if code := errResp["code"]; code != "wallet_session_revoked" {
|
||||||
|
t.Fatalf("expected wallet_session_revoked, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletSessionRefreshRotatesToken(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue wallet session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := postJSONExpectWithHeaders[walletSessionRefreshResponse](t, a, "/secret/wallet/session/refresh", walletSessionRefreshRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
}, http.StatusOK, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if refresh.Status != "session_refreshed" {
|
||||||
|
t.Fatalf("expected refreshed status, got %+v", refresh)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(refresh.SessionToken) == "" || refresh.SessionToken == session.SessionToken {
|
||||||
|
t.Fatalf("expected rotated token, got %+v", refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldBlocked := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusUnauthorized, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if code := oldBlocked["code"]; code != "wallet_session_revoked" {
|
||||||
|
t.Fatalf("expected old token revoked, got %+v", oldBlocked)
|
||||||
|
}
|
||||||
|
newAllowed := postJSONExpectWithHeaders[marketplaceCheckoutQuoteResponse](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusOK, map[string]string{
|
||||||
|
sessionHeaderToken: refresh.SessionToken,
|
||||||
|
})
|
||||||
|
if strings.TrimSpace(newAllowed.QuoteID) == "" {
|
||||||
|
t.Fatalf("expected quote with refreshed token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletSessionRevokeEndpointBlocksFurtherUse(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
session, err := a.issueWalletSession(context.Background(), ownerAddr, "1234567890123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue wallet session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoked := postJSONExpectWithHeaders[walletSessionRevokeResponse](t, a, "/secret/wallet/session/revoke", walletSessionRevokeRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
}, http.StatusOK, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if revoked.Status != "session_revoked" {
|
||||||
|
t.Fatalf("unexpected revoke response: %+v", revoked)
|
||||||
|
}
|
||||||
|
|
||||||
|
errResp := postJSONExpectWithHeaders[map[string]string](t, a, "/marketplace/checkout/quote", marketplaceCheckoutQuoteRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OfferID: offerIDSoloCore,
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
}, http.StatusUnauthorized, map[string]string{
|
||||||
|
sessionHeaderToken: session.SessionToken,
|
||||||
|
})
|
||||||
|
if code := errResp["code"]; code != "wallet_session_revoked" {
|
||||||
|
t.Fatalf("expected revoked token to fail, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberChannelRegisterRequiresWalletSession(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
errResp := postJSONExpect[map[string]string](t, a, "/member/channel/device/register", memberChannelDeviceRegisterRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
ChainID: a.cfg.ChainID,
|
||||||
|
DeviceID: "desktop-local-01",
|
||||||
|
Platform: "desktop",
|
||||||
|
OrgRootID: "org_test",
|
||||||
|
PrincipalID: "principal_test",
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
AppVersion: "0.1.0",
|
||||||
|
PushProvider: "none",
|
||||||
|
}, http.StatusUnauthorized)
|
||||||
|
if code := errResp["code"]; code != "wallet_session_required" {
|
||||||
|
t.Fatalf("expected wallet_session_required, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGovernanceInstallTokenRequiresWalletSession(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
a.cfg.RequireWalletSession = true
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
if err := seedActiveMembership(context.Background(), a.store, ownerAddr); err != nil {
|
||||||
|
t.Fatalf("seed active membership: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := a.store.putGovernancePrincipal(context.Background(), governancePrincipalRecord{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OrgRootID: "org_test",
|
||||||
|
PrincipalID: "principal_test",
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
EntitlementID: "ent_test",
|
||||||
|
EntitlementStatus: "active",
|
||||||
|
AccessClass: "connected",
|
||||||
|
AvailabilityState: "active",
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed governance principal: %v", err)
|
||||||
|
}
|
||||||
|
errResp := postJSONExpect[map[string]string](t, a, "/governance/install/token", governanceInstallTokenRequest{
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
OrgRootID: "org_test",
|
||||||
|
PrincipalID: "principal_test",
|
||||||
|
PrincipalRole: "org_root_owner",
|
||||||
|
DeviceID: "device-test",
|
||||||
|
LauncherVersion: "0.1.0",
|
||||||
|
Platform: "desktop",
|
||||||
|
}, http.StatusUnauthorized)
|
||||||
|
if code := errResp["code"]; code != "wallet_session_required" {
|
||||||
|
t.Fatalf("expected wallet_session_required, got %+v", errResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueWalletSessionPrunesExpired(t *testing.T) {
|
||||||
|
a, _, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ownerKey := mustKey(t)
|
||||||
|
ownerAddr := strings.ToLower(crypto.PubkeyToAddress(ownerKey.PublicKey).Hex())
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expired := walletSessionRecord{
|
||||||
|
SessionToken: "prune-me",
|
||||||
|
Wallet: ownerAddr,
|
||||||
|
DesignationCode: "1111111111111",
|
||||||
|
ChainID: a.cfg.ChainID,
|
||||||
|
IssuedAt: now.Add(-3 * time.Hour),
|
||||||
|
ExpiresAt: now.Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := a.store.putWalletSession(context.Background(), expired); err != nil {
|
||||||
|
t.Fatalf("seed expired session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.issueWalletSession(context.Background(), ownerAddr, "2222222222222"); err != nil {
|
||||||
|
t.Fatalf("issue wallet session: %v", err)
|
||||||
|
}
|
||||||
|
_, err := a.store.getWalletSession(context.Background(), expired.SessionToken)
|
||||||
|
if err != errNotFound {
|
||||||
|
t.Fatalf("expected expired session to be pruned, got err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type tWalletIntentResponse struct {
|
type tWalletIntentResponse struct {
|
||||||
IntentID string `json:"intent_id"`
|
IntentID string `json:"intent_id"`
|
||||||
DesignationCode string `json:"designation_code"`
|
DesignationCode string `json:"designation_code"`
|
||||||
@ -1219,9 +1527,10 @@ type tWalletIntentResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tWalletVerifyResponse struct {
|
type tWalletVerifyResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
DesignationCode string `json:"designation_code"`
|
DesignationCode string `json:"designation_code"`
|
||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
|
SessionExpiresAt string `json:"session_expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestApp(t *testing.T) (*app, Config, func()) {
|
func newTestApp(t *testing.T) (*app, Config, func()) {
|
||||||
|
|||||||
@ -37,6 +37,27 @@ type walletVerifyResponse struct {
|
|||||||
SessionExpires string `json:"session_expires_at,omitempty"`
|
SessionExpires string `json:"session_expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type walletSessionRefreshRequest struct {
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type walletSessionRefreshResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
SessionToken string `json:"session_token"`
|
||||||
|
SessionExpire string `json:"session_expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type walletSessionRevokeRequest struct {
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type walletSessionRevokeResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type membershipQuoteRequest struct {
|
type membershipQuoteRequest struct {
|
||||||
DesignationCode string `json:"designation_code"`
|
DesignationCode string `json:"designation_code"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) {
|
func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) {
|
||||||
|
_, _ = a.store.deleteExpiredWalletSessions(ctx, time.Now().UTC())
|
||||||
token, err := randomHex(24)
|
token, err := randomHex(24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return walletSessionRecord{}, err
|
return walletSessionRecord{}, err
|
||||||
|
|||||||
@ -453,6 +453,40 @@ func (s *store) touchWalletSession(ctx context.Context, token string, touchedAt
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) revokeWalletSession(ctx context.Context, token string, revokedAt time.Time) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE wallet_sessions
|
||||||
|
SET revoked_at = ?
|
||||||
|
WHERE session_token = ?
|
||||||
|
`, revokedAt.UTC().Format(time.RFC3339Nano), strings.TrimSpace(token))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) deleteExpiredWalletSessions(ctx context.Context, now time.Time) (int64, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM wallet_sessions
|
||||||
|
WHERE expires_at <= ?
|
||||||
|
`, now.UTC().Format(time.RFC3339Nano))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) putQuote(ctx context.Context, quote quoteRecord) error {
|
func (s *store) putQuote(ctx context.Context, quote quoteRecord) error {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO quotes (
|
INSERT INTO quotes (
|
||||||
|
|||||||
@ -80,7 +80,65 @@ Error (`400` intent expired):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3) `POST /secret/membership/quote`
|
## 3) `POST /secret/wallet/session/refresh`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
1. `Authorization: Bearer <session_token>` (or `X-Edut-Session`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success (`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "session_refreshed",
|
||||||
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
|
"session_token": "f9bc20f15ecf7fd53f1f4ba8ca774564a1098e6ed9db6f0f",
|
||||||
|
"session_expires_at": "2026-03-18T07:42:10Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error (`401` missing/expired token):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "wallet session required",
|
||||||
|
"code": "wallet_session_required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) `POST /secret/wallet/session/revoke`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
1. `Authorization: Bearer <session_token>` (or `X-Edut-Session`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success (`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "session_revoked",
|
||||||
|
"wallet": "0x3ea6cbf98d23e2cf7b6f4f9bb1fb4f50b710f2d5",
|
||||||
|
"revoked_at": "2026-02-17T07:34:02Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) `POST /secret/membership/quote`
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|
||||||
@ -142,7 +200,7 @@ Error (`403` distinct payer without proof):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4) `POST /secret/membership/confirm`
|
## 6) `POST /secret/membership/confirm`
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|
||||||
@ -184,7 +242,7 @@ Error (`400` tx mismatch):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5) `GET /secret/membership/status`
|
## 7) `GET /secret/membership/status`
|
||||||
|
|
||||||
Request by wallet:
|
Request by wallet:
|
||||||
|
|
||||||
|
|||||||
@ -34,10 +34,70 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Signature verified
|
description: Signature verified
|
||||||
|
headers:
|
||||||
|
X-Edut-Session:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Wallet session token for follow-on wallet-scoped APIs.
|
||||||
|
X-Edut-Session-Expires-At:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Session expiry timestamp.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/WalletVerifyResponse'
|
$ref: '#/components/schemas/WalletVerifyResponse'
|
||||||
|
/secret/wallet/session/refresh:
|
||||||
|
post:
|
||||||
|
summary: Rotate wallet session token
|
||||||
|
description: Requires a valid wallet session token (`Authorization: Bearer` or `X-Edut-Session`).
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WalletSessionHeader'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WalletSessionRefreshRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session rotated
|
||||||
|
headers:
|
||||||
|
X-Edut-Session:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
X-Edut-Session-Expires-At:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WalletSessionRefreshResponse'
|
||||||
|
'401':
|
||||||
|
description: Session missing, invalid, revoked, or expired
|
||||||
|
/secret/wallet/session/revoke:
|
||||||
|
post:
|
||||||
|
summary: Revoke wallet session token
|
||||||
|
description: Requires a valid wallet session token (`Authorization: Bearer` or `X-Edut-Session`).
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WalletSessionHeader'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WalletSessionRevokeRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WalletSessionRevokeResponse'
|
||||||
|
'401':
|
||||||
|
description: Session missing, invalid, revoked, or expired
|
||||||
/secret/membership/quote:
|
/secret/membership/quote:
|
||||||
post:
|
post:
|
||||||
summary: Get current membership mint quote
|
summary: Get current membership mint quote
|
||||||
@ -93,6 +153,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MembershipStatusResponse'
|
$ref: '#/components/schemas/MembershipStatusResponse'
|
||||||
components:
|
components:
|
||||||
|
parameters:
|
||||||
|
WalletSessionHeader:
|
||||||
|
in: header
|
||||||
|
name: X-Edut-Session
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Wallet session token. `Authorization: Bearer <token>` is also accepted.
|
||||||
schemas:
|
schemas:
|
||||||
WalletIntentRequest:
|
WalletIntentRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -166,6 +234,46 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Session token expiry timestamp.
|
description: Session token expiry timestamp.
|
||||||
|
WalletSessionRefreshRequest:
|
||||||
|
type: object
|
||||||
|
required: [wallet]
|
||||||
|
properties:
|
||||||
|
wallet:
|
||||||
|
type: string
|
||||||
|
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||||
|
WalletSessionRefreshResponse:
|
||||||
|
type: object
|
||||||
|
required: [status, wallet, session_token, session_expires_at]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [session_refreshed]
|
||||||
|
wallet:
|
||||||
|
type: string
|
||||||
|
session_token:
|
||||||
|
type: string
|
||||||
|
session_expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
WalletSessionRevokeRequest:
|
||||||
|
type: object
|
||||||
|
required: [wallet]
|
||||||
|
properties:
|
||||||
|
wallet:
|
||||||
|
type: string
|
||||||
|
pattern: '^0x[a-fA-F0-9]{40}$'
|
||||||
|
WalletSessionRevokeResponse:
|
||||||
|
type: object
|
||||||
|
required: [status, wallet, revoked_at]
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [session_revoked]
|
||||||
|
wallet:
|
||||||
|
type: string
|
||||||
|
revoked_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
MembershipQuoteRequest:
|
MembershipQuoteRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [designation_code, address, chain_id]
|
required: [designation_code, address, chain_id]
|
||||||
|
|||||||
@ -63,6 +63,8 @@ Post-mint success -> app download links (Desktop/iOS/Android)
|
|||||||
| Landing UI | `edut.ai`, `edut.dev` | Continue flow + wallet intent/signature + membership mint UX |
|
| Landing UI | `edut.ai`, `edut.dev` | Continue flow + wallet intent/signature + membership mint UX |
|
||||||
| API | `api.edut.ai/secret/wallet/intent` | Create one-time designation intent |
|
| API | `api.edut.ai/secret/wallet/intent` | Create one-time designation intent |
|
||||||
| API | `api.edut.ai/secret/wallet/verify` | Verify signature and bind wallet identity |
|
| API | `api.edut.ai/secret/wallet/verify` | Verify signature and bind wallet identity |
|
||||||
|
| API | `api.edut.ai/secret/wallet/session/refresh` | Rotate wallet session token |
|
||||||
|
| API | `api.edut.ai/secret/wallet/session/revoke` | Revoke wallet session token |
|
||||||
| API | `api.edut.ai/secret/membership/quote` | Return current payable membership quote |
|
| API | `api.edut.ai/secret/membership/quote` | Return current payable membership quote |
|
||||||
| API | `api.edut.ai/secret/membership/confirm` | Confirm membership tx and activation state |
|
| API | `api.edut.ai/secret/membership/confirm` | Confirm membership tx and activation state |
|
||||||
| Chain | Base | Membership mint settlement and evidence |
|
| Chain | Base | Membership mint settlement and evidence |
|
||||||
@ -160,11 +162,77 @@ Response:
|
|||||||
"status": "signature_verified",
|
"status": "signature_verified",
|
||||||
"designation_code": "0217073045482",
|
"designation_code": "0217073045482",
|
||||||
"display_token": "0217-0730-4548-2",
|
"display_token": "0217-0730-4548-2",
|
||||||
"verified_at": "2026-02-17T07:31:12Z"
|
"verified_at": "2026-02-17T07:31:12Z",
|
||||||
|
"session_token": "9f2c50f8a0f5d8d0b0efc4fa665e4032f31bb0c4c4f31b8c",
|
||||||
|
"session_expires_at": "2026-03-18T07:31:12Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3) Membership Quote
|
### 3) Wallet Session Lifecycle
|
||||||
|
|
||||||
|
#### `POST /secret/wallet/session/refresh`
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wallet": "0xabc123..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. Valid wallet session token via `Authorization: Bearer <token>` or `X-Edut-Session`.
|
||||||
|
2. Session wallet must match request wallet.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Validate current token state (not expired/revoked, chain matches).
|
||||||
|
2. Revoke the presented token.
|
||||||
|
3. Issue a replacement token with fresh expiry.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "session_refreshed",
|
||||||
|
"wallet": "0xabc123...",
|
||||||
|
"session_token": "f9bc20f15ecf7fd53f1f4ba8ca774564a1098e6ed9db6f0f",
|
||||||
|
"session_expires_at": "2026-03-18T07:42:10Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /secret/wallet/session/revoke`
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wallet": "0xabc123..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. Valid wallet session token via `Authorization: Bearer <token>` or `X-Edut-Session`.
|
||||||
|
2. Session wallet must match request wallet.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Validate current token state.
|
||||||
|
2. Mark session as revoked immediately.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "session_revoked",
|
||||||
|
"wallet": "0xabc123...",
|
||||||
|
"revoked_at": "2026-02-17T07:34:02Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Membership Quote
|
||||||
|
|
||||||
#### `POST /secret/membership/quote`
|
#### `POST /secret/membership/quote`
|
||||||
|
|
||||||
@ -200,7 +268,7 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4) Membership Confirm
|
### 5) Membership Confirm
|
||||||
|
|
||||||
#### `POST /secret/membership/confirm`
|
#### `POST /secret/membership/confirm`
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user