From f1eb34f5b364f5b3fc9907731e30a425cfd867aa Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 20:42:04 -0800 Subject: [PATCH] Add wallet session lifecycle endpoints and docs --- backend/secretapi/README.md | 7 + backend/secretapi/app.go | 96 ++++++ backend/secretapi/app_test.go | 315 +++++++++++++++++++- backend/secretapi/models.go | 21 ++ backend/secretapi/session_auth.go | 1 + backend/secretapi/store.go | 34 +++ docs/api/examples/secret-system.examples.md | 64 +++- docs/api/secret-system.openapi.yaml | 108 +++++++ docs/secret-system-spec.md | 74 ++++- 9 files changed, 711 insertions(+), 9 deletions(-) diff --git a/backend/secretapi/README.md b/backend/secretapi/README.md index 08d5922..64a92b4 100644 --- a/backend/secretapi/README.md +++ b/backend/secretapi/README.md @@ -29,6 +29,8 @@ Copy `.env.example` in this folder and set contract/runtime values before deploy - `POST /secret/wallet/intent` - `POST /secret/wallet/verify` +- `POST /secret/wallet/session/refresh` +- `POST /secret/wallet/session/revoke` - `POST /secret/membership/quote` - `POST /secret/membership/confirm` - `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. +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 Membership quote supports ownership wallet and distinct payer wallet: diff --git a/backend/secretapi/app.go b/backend/secretapi/app.go index 72cfbfb..4547156 100644 --- a/backend/secretapi/app.go +++ b/backend/secretapi/app.go @@ -34,6 +34,8 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/healthz", a.withCORS(a.handleHealth)) mux.HandleFunc("/secret/wallet/intent", a.withCORS(a.handleWalletIntent)) 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/confirm", a.withCORS(a.handleMembershipConfirm)) 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) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1476,4 +1569,7 @@ func isTxHash(value string) bool { 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) + if !cfg.RequireWalletSession { + log.Printf("warning: wallet session enforcement is disabled (SECRET_API_REQUIRE_WALLET_SESSION=false)") + } } diff --git a/backend/secretapi/app_test.go b/backend/secretapi/app_test.go index 2a0d036..ed1d17c 100644 --- a/backend/secretapi/app_test.go +++ b/backend/secretapi/app_test.go @@ -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 { IntentID string `json:"intent_id"` DesignationCode string `json:"designation_code"` @@ -1219,9 +1527,10 @@ type tWalletIntentResponse struct { } type tWalletVerifyResponse struct { - Status string `json:"status"` - DesignationCode string `json:"designation_code"` - SessionToken string `json:"session_token"` + Status string `json:"status"` + DesignationCode string `json:"designation_code"` + SessionToken string `json:"session_token"` + SessionExpiresAt string `json:"session_expires_at"` } func newTestApp(t *testing.T) (*app, Config, func()) { diff --git a/backend/secretapi/models.go b/backend/secretapi/models.go index 6bd366b..99df80d 100644 --- a/backend/secretapi/models.go +++ b/backend/secretapi/models.go @@ -37,6 +37,27 @@ type walletVerifyResponse struct { 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 { DesignationCode string `json:"designation_code"` Address string `json:"address"` diff --git a/backend/secretapi/session_auth.go b/backend/secretapi/session_auth.go index 315c052..529d78a 100644 --- a/backend/secretapi/session_auth.go +++ b/backend/secretapi/session_auth.go @@ -13,6 +13,7 @@ const ( ) func (a *app) issueWalletSession(ctx context.Context, wallet, designationCode string) (walletSessionRecord, error) { + _, _ = a.store.deleteExpiredWalletSessions(ctx, time.Now().UTC()) token, err := randomHex(24) if err != nil { return walletSessionRecord{}, err diff --git a/backend/secretapi/store.go b/backend/secretapi/store.go index a3b394d..98507fa 100644 --- a/backend/secretapi/store.go +++ b/backend/secretapi/store.go @@ -453,6 +453,40 @@ func (s *store) touchWalletSession(ctx context.Context, token string, touchedAt 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 { _, err := s.db.ExecContext(ctx, ` INSERT INTO quotes ( diff --git a/docs/api/examples/secret-system.examples.md b/docs/api/examples/secret-system.examples.md index 0f90d8d..10632a6 100644 --- a/docs/api/examples/secret-system.examples.md +++ b/docs/api/examples/secret-system.examples.md @@ -80,7 +80,65 @@ Error (`400` intent expired): } ``` -## 3) `POST /secret/membership/quote` +## 3) `POST /secret/wallet/session/refresh` + +Request: + +Headers: + +1. `Authorization: Bearer ` (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 ` (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: @@ -142,7 +200,7 @@ Error (`403` distinct payer without proof): } ``` -## 4) `POST /secret/membership/confirm` +## 6) `POST /secret/membership/confirm` Request: @@ -184,7 +242,7 @@ Error (`400` tx mismatch): } ``` -## 5) `GET /secret/membership/status` +## 7) `GET /secret/membership/status` Request by wallet: diff --git a/docs/api/secret-system.openapi.yaml b/docs/api/secret-system.openapi.yaml index d11d5a9..8e5b688 100644 --- a/docs/api/secret-system.openapi.yaml +++ b/docs/api/secret-system.openapi.yaml @@ -34,10 +34,70 @@ paths: responses: '200': 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: application/json: schema: $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: post: summary: Get current membership mint quote @@ -93,6 +153,14 @@ paths: schema: $ref: '#/components/schemas/MembershipStatusResponse' components: + parameters: + WalletSessionHeader: + in: header + name: X-Edut-Session + required: false + schema: + type: string + description: Wallet session token. `Authorization: Bearer ` is also accepted. schemas: WalletIntentRequest: type: object @@ -166,6 +234,46 @@ components: type: string format: date-time 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: type: object required: [designation_code, address, chain_id] diff --git a/docs/secret-system-spec.md b/docs/secret-system-spec.md index fc3433b..2524e8b 100644 --- a/docs/secret-system-spec.md +++ b/docs/secret-system-spec.md @@ -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 | | 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/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/confirm` | Confirm membership tx and activation state | | Chain | Base | Membership mint settlement and evidence | @@ -160,11 +162,77 @@ Response: "status": "signature_verified", "designation_code": "0217073045482", "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 ` 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 ` 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` @@ -200,7 +268,7 @@ Response: } ``` -### 4) Membership Confirm +### 5) Membership Confirm #### `POST /secret/membership/confirm`